diff --git a/assets/management-api/access-tokens.png b/assets/management-api/access-tokens.png index 98bf40138c6..f57a7b0bda2 100644 Binary files a/assets/management-api/access-tokens.png and b/assets/management-api/access-tokens.png differ diff --git a/assets/management-api/generate-access-token-dialog.png b/assets/management-api/generate-access-token-dialog.png index 0b1fc6338ae..20eb105493c 100644 Binary files a/assets/management-api/generate-access-token-dialog.png and b/assets/management-api/generate-access-token-dialog.png differ diff --git a/assets/nextjs-starter-kit/starter-kit-apps.mp4 b/assets/nextjs-starter-kit/starter-kit-apps.mp4 new file mode 100644 index 00000000000..8ba274a48a4 Binary files /dev/null and b/assets/nextjs-starter-kit/starter-kit-apps.mp4 differ diff --git a/assets/nextjs-starter-kit/starter-kit.mp4 b/assets/nextjs-starter-kit/starter-kit.mp4 index 6bb924a96ea..fb7977c1286 100644 Binary files a/assets/nextjs-starter-kit/starter-kit.mp4 and b/assets/nextjs-starter-kit/starter-kit.mp4 differ diff --git a/docs/pages/platform/management-api.mdx b/docs/pages/platform/management-api.mdx index 969dbcb8be4..0e6226f523b 100644 --- a/docs/pages/platform/management-api.mdx +++ b/docs/pages/platform/management-api.mdx @@ -121,19 +121,28 @@ delete icon next to it. ## Access tokens expiry -All Management API access tokens expire after one year from the date of -generation. The expiration date is displayed when you generate a new token, and -you can view it for existing tokens in the Management API tokens list. +All Management API access tokens have an expiration date. When generating a new +token, you can choose from the following expiration options: + +- **7 days**: Token expires 7 days from creation +- **30 days**: Token expires 30 days from creation +- **90 days**: Token expires 90 days from creation +- **1 year**: Token expires 1 year from creation (default) +- **Custom date**: Choose a specific expiration date, up to a maximum of 1 year + from today + +The expiration date is displayed when you generate a new token, and you can view +it for existing tokens in the Management API tokens list. When a token expires, you'll need to generate a new token to continue using the Management API. Make sure to rotate your tokens before they expire to avoid interruptions in your API access. - - Custom expiration dates will be available in a future update. If you'd like to - provide feedback or have specific scope requirements, please contact - [sales@liveblocks.io](mailto:sales@liveblocks.io). - +### Expiration reminders + +Liveblocks sends email reminders to team owners before tokens expire. You'll +receive notifications at 7 days and 1 day before expiration, giving you time to +generate replacement tokens and update your integrations. ## Revoking an access token diff --git a/docs/pages/tools/nextjs-starter-kit.mdx b/docs/pages/tools/nextjs-starter-kit.mdx index fc9b3f826cf..2c4f1d9a216 100644 --- a/docs/pages/tools/nextjs-starter-kit.mdx +++ b/docs/pages/tools/nextjs-starter-kit.mdx @@ -11,7 +11,7 @@ The Next.js Starter Kit is an open-source template that showcases all aspects of Liveblocks in a single, modern application.
-
@@ -28,10 +28,21 @@ YouTube, or [try the project online](https://nextjs-starter-kit.liveblocks.app). The Next.js Starter Kit includes the following -- Documents dashboard with pagination, drafts, groups, auto-revalidation -- Collaborative whiteboard app with a fully-featured share menu -- Authentication compatible with GitHub, Google, Auth0, and more -- Document permissions can be scoped to users, groups, and the public +- Documents dashboard with pagination, organizations, auto-revalidation. +- Collaborative apps including a whiteboard, text documents, note editor, + drawing canvas. +- Realtime presence with avatars, cursors, carets. +- Fully-featured share menu with users, organizations, public permissions. +- Authentication compatible with GitHub, Google, Auth0, and more. + +
+ +
## Set up the Next.js Starter Kit @@ -86,7 +97,7 @@ Vercel. #### Connect to Liveblocks and retrieve your secret key If you prefer to work locally, you can tell the installer you would not like to -"deploy to Vercel." After declining the deployment option, the installer will +"deploy to Vercel". After declining the deployment option, the installer will prompt adding your Liveblocks Key to the application automatically. If you forwent deployment or indicated that you would like to get your Liveblocks secret key automatically, the Liveblocks integration page will open in a new @@ -149,7 +160,7 @@ up your GitHub secret key and client id. Almost there! `.env.local` should now contain lines similar to this: -```dotenv file=".env.local" +```env file=".env.local" GITHUB_CLIENT_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX GITHUB_CLIENT_ID=XXXXXXXXXXXXXXXXXXXX ``` @@ -191,7 +202,7 @@ up your Auth0 secret key and client information. `.env.local` should now contain these three lines, along with anything previously there: -```dotenv file=".env.local" +```env file=".env.local" AUTH0_CLIENT_ID=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX AUTH0_CLIENT_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX AUTH0_ISSUER_BASE_URL=https://XXXXXXXXXXXXXXXXXX.com @@ -224,10 +235,10 @@ Navigate there and add your details, for example, if you’re signing in with ```ts file="data/users.ts" { - id: "yourname@example.com", + id: "your.name@example.com", name: "Your Name", avatar: "https://liveblocks.io/avatars/avatar-0.png", - groupIds: ["product", "engineering", "design"], + organizationIds: ["liveblocks", "your.name@example.com"], }, ``` @@ -266,8 +277,10 @@ lib primitives styles types +utils auth.ts auth.config.ts +constants.ts liveblocks.config.ts liveblocks.server.config.ts package.json @@ -314,6 +327,8 @@ logic to create and modify documents.
File that links NextAuth to your database.
`/auth.config.ts`
File where NextAuth providers are set up.
+
`/constants.ts`
+
Constant strings used in the app, such as URLs and global IDs.
`/liveblocks.config.ts`
Liveblocks config file.
`/liveblocks.server.config.ts`
@@ -632,6 +647,7 @@ correct properties: - [`/lib/database/getGroup.ts`](https://github.com/liveblocks/liveblocks/blob/main/starter-kits/nextjs-starter-kit/lib/database/getGroup.ts) - [`/lib/database/getGroups.ts`](https://github.com/liveblocks/liveblocks/blob/main/starter-kits/nextjs-starter-kit/lib/database/getGroups.ts) +- [`/lib/database/getOrganization.ts`](https://github.com/liveblocks/liveblocks/blob/main/starter-kits/nextjs-starter-kit/lib/database/getOrganization.ts) - [`/lib/database/getUser.ts`](https://github.com/liveblocks/liveblocks/blob/main/starter-kits/nextjs-starter-kit/lib/database/getUser.ts) You can then remove the `/data` folder. Everything else should work as expected. diff --git a/packages/liveblocks-server/README.md b/packages/liveblocks-server/README.md index 9217ea9b342..0ecc195f2f2 100644 --- a/packages/liveblocks-server/README.md +++ b/packages/liveblocks-server/README.md @@ -16,56 +16,22 @@ Size - - License + + License

-`@liveblocks/server` provides the APIs to run a Liveblocks server yourself. - -## Installation - -``` -bun install @liveblocks/server -``` - -## Architecture - -```mermaid -classDiagram - Room --> Store : store - Room --> "0+" Session : sessions - Session --> WebSocket - - class Room { - +roomId - +load() - +createTicket(version) Ticket - +startBrowserSession(Ticket ticket, WebSocket socket) - +handleRaw(Ticket ticket, data) - +endBrowserSession(Ticket ticket, code, reason) - } - - class Store { - +getString(key) - +getNumber(key) - +put(key, value) - } - - class Session{ - +version - +actor - +nonce - +createdAt - +lastPong - +sendPong() - +sendServerMsg() - } - - class WebSocket { - +... - } -``` +`@liveblocks/server` provides the core Liveblocks server functionality. It +powers both the Liveblocks production environment, and +[our dev server](https://liveblocks.io/docs/tools/dev-server), so the behavior +is identical. You typically don't need to install or use this package directly. + +While `@liveblocks/server` contains the same core technology that powers +Liveblocks, we do not yet offer or recommend self-hosting or on-premises +deployment. The purpose of open sourcing the server package is to provide +transparency, and offer a great local development and testing experience. A +production-ready self-hosted deployment requires additional infrastructure and +expertise that are not yet easy to package. ## License diff --git a/packages/liveblocks-server/package.json b/packages/liveblocks-server/package.json index 6cc1e1fc9b4..20c0d570fe3 100644 --- a/packages/liveblocks-server/package.json +++ b/packages/liveblocks-server/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/server", - "version": "1.0.11", + "version": "1.0.12", "description": "Liveblocks backend server foundation.", "type": "module", "main": "./dist/index.js", @@ -57,7 +57,7 @@ "dependencies": { "@liveblocks/core": "3.14.0", "async-mutex": "^0.4.0", - "decoders": "^2.8.0-1", + "decoders": "^2.8.0", "itertools": "^2.3.2", "js-base64": "^3.7.5", "nanoid": "^3", diff --git a/packages/liveblocks-zenrouter/README.md b/packages/liveblocks-zenrouter/README.md index d37c46c7bae..3b2923e60da 100644 --- a/packages/liveblocks-zenrouter/README.md +++ b/packages/liveblocks-zenrouter/README.md @@ -1,7 +1,28 @@ -Zen Router - -Zen Router is an opinionated router, with batteries included and encouraging -patterns that remain maintable over time as your application grows. +

+ + Liveblocks + + + Liveblocks + +

+ +# `@liveblocks/zenrouter` + +

+ + NPM + + + Size + + + License + +

+ +Zen Router is an opinionated API router with batteries included, encouraging +patterns that remain maintainable as your application grows. ## Installation @@ -102,3 +123,10 @@ Maintainability: - Default error handling can be configured on a per-status code basis (used when handlers throw a (custom) HttpError), individual requests can always bypass this by throwing a custom Response. + +## License + +Licensed under the Apache License 2.0, Copyright © 2021-present +[Liveblocks](https://liveblocks.io). + +See [LICENSE](../../licenses/LICENSE-APACHE-2.0) for more information. diff --git a/packages/liveblocks-zenrouter/assets/zen-router.webp b/packages/liveblocks-zenrouter/assets/zen-router.webp deleted file mode 100644 index 1816d38800a..00000000000 Binary files a/packages/liveblocks-zenrouter/assets/zen-router.webp and /dev/null differ diff --git a/packages/liveblocks-zenrouter/package.json b/packages/liveblocks-zenrouter/package.json index e679e61dc5a..86090c677dd 100644 --- a/packages/liveblocks-zenrouter/package.json +++ b/packages/liveblocks-zenrouter/package.json @@ -1,6 +1,6 @@ { "name": "@liveblocks/zenrouter", - "version": "1.0.11", + "version": "1.0.12", "description": "An opinionated router library for building APIs following best practices.", "type": "module", "main": "./dist/index.js", @@ -38,12 +38,10 @@ "license": "Apache-2.0", "devDependencies": { "@liveblocks/eslint-config": "*", + "decoders": "^2.8.0", "hotscript": "^1.0.13", - "nanoid": "^3" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.9.0", - "decoders": "^2.8.0-1" + "nanoid": "^3", + "zod": "^4.1.8" }, "repository": { "type": "git", diff --git a/packages/liveblocks-zenrouter/src/Router.ts b/packages/liveblocks-zenrouter/src/Router.ts index e05c0c73254..ca078f780c5 100644 --- a/packages/liveblocks-zenrouter/src/Router.ts +++ b/packages/liveblocks-zenrouter/src/Router.ts @@ -2,18 +2,17 @@ // TODO: Make this a local definition? import type { Json, JsonObject } from "@liveblocks/core"; -import { context, trace } from "@opentelemetry/api"; -import type { Decoder } from "decoders"; -import { formatShort } from "decoders"; import type { ExtractParams, HttpVerb, - MapDecoderTypes, + MapSchemaOutput, Pattern, RouteMatcher, } from "~/lib/matchers.js"; import { routeMatcher, sortHttpVerbsInPlace } from "~/lib/matchers.js"; +import type { OtelConfig } from "~/lib/otel.js"; +import type { StandardSchemaV1 } from "~/lib/standard-schema.js"; import { mapv, raise } from "~/lib/utils.js"; import type { HttpError } from "~/responses/index.js"; import { abort, json, ValidationError } from "~/responses/index.js"; @@ -104,11 +103,11 @@ type RouteTuple = readonly [ pattern: Pattern, matcher: RouteMatcher, auth: AuthFn, - bodyDecoder: Decoder | null, + bodySchema: StandardSchemaV1 | null, handler: OpaqueRouteHandler, ]; -type RouterOptions>> = { +type RouterOptions> = { errorHandler?: ErrorHandler; // Mandatory config @@ -129,6 +128,12 @@ type RouterOptions>> = { // Register any param decoders params?: TParams; + /** + * Optional OpenTelemetry integration. When provided, matched route patterns + * and decoded params will be set as span attributes. + */ + otel?: OtelConfig; + // Optional config debug?: boolean; }; @@ -146,7 +151,7 @@ type OpaqueParams = Record; export class ZenRouter< RC, AC, - TParams extends Record> = {}, + TParams extends Record = {}, > { #_debug: boolean; #_contextFn: (req: Request, ...args: readonly any[]) => RC; @@ -155,6 +160,7 @@ export class ZenRouter< #_paramDecoders: TParams; #_errorHandler: ErrorHandler; #_cors: Partial | null; + #_otel: OtelConfig | undefined; constructor(options?: RouterOptions) { this.#_errorHandler = options?.errorHandler ?? new ErrorHandler(); @@ -170,6 +176,7 @@ export class ZenRouter< this.#_routes = []; this.#_paramDecoders = options?.params ?? ({} as TParams); this.#_cors = (options?.cors === true ? {} : options?.cors) || null; + this.#_otel = options?.otel; } // --- PUBLIC APIs ----------------------------------------------------------------- @@ -193,17 +200,17 @@ export class ZenRouter< handler: RouteHandler< RC, AC, - ExtractParams>, + ExtractParams>, never > ): void; public route

( pattern: P, - bodyDecoder: Decoder, + bodySchema: StandardSchemaV1, handler: RouteHandler< RC, AC, - ExtractParams>, + ExtractParams>, TBody > ): void; @@ -212,12 +219,12 @@ export class ZenRouter< public route(first: any, second: any, third?: any): void { /* eslint-enable @typescript-eslint/explicit-module-boundary-types */ const pattern = first; - const bodyDecoder = arguments.length >= 3 ? second : null; + const bodySchema = arguments.length >= 3 ? second : null; const handler = arguments.length >= 3 ? third : second; /* eslint-enable @typescript-eslint/no-unsafe-assignment */ this.#_register( pattern, - bodyDecoder, + bodySchema, handler as RouteHandler ); } @@ -255,7 +262,7 @@ export class ZenRouter< #_register

( pattern: P, - bodyDecoder: Decoder | null, + bodySchema: StandardSchemaV1 | null, handler: RouteHandler // authFn?: OpaqueAuthFn ): void { @@ -265,7 +272,7 @@ export class ZenRouter< pattern, matcher, /* authFn ?? */ this.#_defaultAuthFn, - bodyDecoder, + bodySchema, wrap(handler), ]); } @@ -343,7 +350,7 @@ export class ZenRouter< // Match routes in the given order let pathDidMatch = false; for (const tup of this.#_routes) { - const [pattern, matcher, authorize, bodyDecoder, handler] = tup; + const [pattern, matcher, authorize, bodySchema, handler] = tup; const match = matcher.matchURL(url); if (match === null) { @@ -362,7 +369,7 @@ export class ZenRouter< // Add route pattern as span attribute // This is done early so the route is recorded even for auth/validation errors - const span = trace.getSpan(context.active()); + const span = this.#_otel?.getActiveSpan(); span?.setAttribute("zen.route", pattern); const base = { @@ -382,8 +389,11 @@ export class ZenRouter< try { p = mapv(match, decodeURIComponent); p = mapv(p, (value, key) => { - const decoder = this.#_paramDecoders[key]; - return decoder === undefined ? value : decoder.verify(value); + const schema = this.#_paramDecoders[key]; + if (!schema) return value; + const result = validateSync(schema, value); + if (result.issues) throw result.issues; + return result.value; }); } catch (err) { // A malformed URI that cannot be decoded properly or a param that @@ -396,14 +406,14 @@ export class ZenRouter< span?.setAttribute(`zen.param.${key}`, String(value)); } - const decodeResult = bodyDecoder + const bodyResult = bodySchema ? // TODO: This can throw if the body does not contain a valid JSON // request. If so, we should return a 400. - bodyDecoder.decode(await tryReadBodyAsJson(req)) + validateSync(bodySchema, await tryReadBodyAsJson(req)) : null; - if (decodeResult && !decodeResult.ok) { - const errmsg = formatShort(decodeResult.error); + if (bodyResult?.issues) { + const errmsg = bodyResult.issues.map(formatIssue).join("\n"); throw new ValidationError(errmsg); } @@ -414,10 +424,10 @@ export class ZenRouter< p, q: Object.fromEntries(url.searchParams), get body() { - if (decodeResult === null) { - raise("Cannot access body: this endpoint did not define a body decoder"); // prettier-ignore + if (bodyResult === null) { + raise("Cannot access body: this endpoint did not define a body schema"); // prettier-ignore } - return decodeResult.value; + return bodyResult.value; }, }; @@ -484,6 +494,40 @@ export class ZenRouter< } } +function formatIssue(issue: StandardSchemaV1.Issue): string { + if (!issue.path?.length) return issue.message; + const keys = issue.path.map((p) => (typeof p === "object" ? p.key : p)); + let prefix: string; + if (keys.length === 1) { + prefix = + typeof keys[0] === "number" + ? `Value at index ${keys[0]}` + : `Value at key '${String(keys[0])}'`; + } else { + prefix = `Value at keypath '${keys.join(".")}'`; + } + return `${prefix}: ${issue.message}`; +} + +function isPromiseLike(value: any): value is Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return typeof value?.then === "function"; +} + +/** + * Synchronously validates a value against a Standard Schema. Throws if the + * schema returns a Promise (async validation is not supported). + */ +function validateSync( + schema: StandardSchemaV1, + value: unknown +): StandardSchemaV1.Result { + const result = schema["~standard"].validate(value); + if (isPromiseLike(result)) + throw new Error("Async validation is not supported"); + return result; +} + /** * Helper to handle any endpoint handlers returning a JSON object, and turning * that into a 200 response if so. diff --git a/packages/liveblocks-zenrouter/src/index.ts b/packages/liveblocks-zenrouter/src/index.ts index 8a4971e648f..600ea93986b 100644 --- a/packages/liveblocks-zenrouter/src/index.ts +++ b/packages/liveblocks-zenrouter/src/index.ts @@ -1,3 +1,5 @@ +export * from "~/lib/otel.js"; + // Simple response generators: json(), empty(), abort(), etc. export * from "~/responses/index.js"; diff --git a/packages/liveblocks-zenrouter/src/lib/matchers.ts b/packages/liveblocks-zenrouter/src/lib/matchers.ts index fc6b727d97f..fcb18927ce3 100644 --- a/packages/liveblocks-zenrouter/src/lib/matchers.ts +++ b/packages/liveblocks-zenrouter/src/lib/matchers.ts @@ -1,5 +1,4 @@ import type { Resolve } from "@liveblocks/core"; -import type { Decoder } from "decoders"; import type { ComposeLeft, Objects, @@ -9,6 +8,7 @@ import type { Unions, } from "hotscript"; +import type { StandardSchemaV1 } from "./standard-schema.js"; import { raise } from "./utils.js"; const cleanSegmentRe = /^[\w-]+$/; @@ -80,9 +80,9 @@ type ExtractParamsBasic

= Pipe< * For: * * { - * a: Decoder, - * b: Decoder<'hi'>, - * c: Decoder, + * a: StandardSchemaV1, + * b: StandardSchemaV1, + * c: StandardSchemaV1, * } * * Will return: @@ -94,8 +94,10 @@ type ExtractParamsBasic

= Pipe< * } * */ -export type MapDecoderTypes = { - [K in keyof T]: T[K] extends Decoder ? V : never; +export type MapSchemaOutput = { + [K in keyof T]: T[K] extends StandardSchemaV1 + ? StandardSchemaV1.InferOutput + : never; }; // export type WithDefaults = Pipe<>; diff --git a/packages/liveblocks-zenrouter/src/lib/otel.ts b/packages/liveblocks-zenrouter/src/lib/otel.ts new file mode 100644 index 00000000000..565add5775b --- /dev/null +++ b/packages/liveblocks-zenrouter/src/lib/otel.ts @@ -0,0 +1,7 @@ +export interface OtelSpan { + setAttribute(key: string, value: string): void; +} + +export interface OtelConfig { + getActiveSpan: () => OtelSpan | undefined; +} diff --git a/packages/liveblocks-zenrouter/src/lib/standard-schema.d.ts b/packages/liveblocks-zenrouter/src/lib/standard-schema.d.ts new file mode 100644 index 00000000000..eba310cfcf1 --- /dev/null +++ b/packages/liveblocks-zenrouter/src/lib/standard-schema.d.ts @@ -0,0 +1,175 @@ +// ######################### +// ### Standard Typed ### +// ######################### + +/** The Standard Typed interface. This is a base type extended by other specs. */ +export interface StandardTypedV1 { + /** The Standard properties. */ + readonly "~standard": StandardTypedV1.Props; +} + +export declare namespace StandardTypedV1 { + /** The Standard Typed properties interface. */ + export interface Props { + /** The version number of the standard. */ + readonly version: 1; + /** The vendor name of the schema library. */ + readonly vendor: string; + /** Inferred types associated with the schema. */ + readonly types?: Types | undefined; + } + + /** The Standard Typed types interface. */ + export interface Types { + /** The input type of the schema. */ + readonly input: Input; + /** The output type of the schema. */ + readonly output: Output; + } + + /** Infers the input type of a Standard Typed. */ + export type InferInput = NonNullable< + Schema["~standard"]["types"] + >["input"]; + + /** Infers the output type of a Standard Typed. */ + export type InferOutput = NonNullable< + Schema["~standard"]["types"] + >["output"]; +} + +// ########################## +// ### Standard Schema ### +// ########################## + +/** The Standard Schema interface. */ +export interface StandardSchemaV1 { + /** The Standard Schema properties. */ + readonly "~standard": StandardSchemaV1.Props; +} + +export declare namespace StandardSchemaV1 { + /** The Standard Schema properties interface. */ + export interface Props + extends StandardTypedV1.Props { + /** Validates unknown input values. */ + readonly validate: ( + value: unknown, + options?: StandardSchemaV1.Options | undefined + ) => Result | Promise>; + } + + /** The result interface of the validate function. */ + export type Result = SuccessResult | FailureResult; + + /** The result interface if validation succeeds. */ + export interface SuccessResult { + /** The typed output value. */ + readonly value: Output; + /** A falsy value for `issues` indicates success. */ + readonly issues?: undefined; + } + + export interface Options { + /** Explicit support for additional vendor-specific parameters, if needed. */ + readonly libraryOptions?: Record | undefined; + } + + /** The result interface if validation fails. */ + export interface FailureResult { + /** The issues of failed validation. */ + readonly issues: ReadonlyArray; + } + + /** The issue interface of the failure output. */ + export interface Issue { + /** The error message of the issue. */ + readonly message: string; + /** The path of the issue, if any. */ + readonly path?: ReadonlyArray | undefined; + } + + /** The path segment interface of the issue. */ + export interface PathSegment { + /** The key representing a path segment. */ + readonly key: PropertyKey; + } + + /** The Standard types interface. */ + export interface Types + extends StandardTypedV1.Types {} + + /** Infers the input type of a Standard. */ + export type InferInput = + StandardTypedV1.InferInput; + + /** Infers the output type of a Standard. */ + export type InferOutput = + StandardTypedV1.InferOutput; +} + +// ############################### +// ### Standard JSON Schema ### +// ############################### + +/** The Standard JSON Schema interface. */ +export interface StandardJSONSchemaV1 { + /** The Standard JSON Schema properties. */ + readonly "~standard": StandardJSONSchemaV1.Props; +} + +export declare namespace StandardJSONSchemaV1 { + /** The Standard JSON Schema properties interface. */ + export interface Props + extends StandardTypedV1.Props { + /** Methods for generating the input/output JSON Schema. */ + readonly jsonSchema: StandardJSONSchemaV1.Converter; + } + + /** The Standard JSON Schema converter interface. */ + export interface Converter { + /** Converts the input type to JSON Schema. May throw if conversion is not supported. */ + readonly input: ( + options: StandardJSONSchemaV1.Options + ) => Record; + /** Converts the output type to JSON Schema. May throw if conversion is not supported. */ + readonly output: ( + options: StandardJSONSchemaV1.Options + ) => Record; + } + + /** + * The target version of the generated JSON Schema. + * + * It is *strongly recommended* that implementers support `"draft-2020-12"` and `"draft-07"`, as they are both in wide use. All other targets can be implemented on a best-effort basis. Libraries should throw if they don't support a specified target. + * + * The `"openapi-3.0"` target is intended as a standardized specifier for OpenAPI 3.0 which is a superset of JSON Schema `"draft-04"`. + */ + export type Target = + | "draft-2020-12" + | "draft-07" + | "openapi-3.0" + // Accepts any string for future targets while preserving autocomplete + | ({} & string); + + /** The options for the input/output methods. */ + export interface Options { + /** Specifies the target version of the generated JSON Schema. Support for all versions is on a best-effort basis. If a given version is not supported, the library should throw. */ + readonly target: Target; + + /** Explicit support for additional vendor-specific parameters, if needed. */ + readonly libraryOptions?: Record | undefined; + } + + /** The Standard types interface. */ + export interface Types + extends StandardTypedV1.Types {} + + /** Infers the input type of a Standard. */ + export type InferInput = + StandardTypedV1.InferInput; + + /** Infers the output type of a Standard. */ + export type InferOutput = + StandardTypedV1.InferOutput; +} diff --git a/packages/liveblocks-zenrouter/src/responses/index.ts b/packages/liveblocks-zenrouter/src/responses/index.ts index 57ef8eac7fb..713230fea2c 100644 --- a/packages/liveblocks-zenrouter/src/responses/index.ts +++ b/packages/liveblocks-zenrouter/src/responses/index.ts @@ -1,4 +1,4 @@ -import type { JSONObject, JSONValue } from "decoders"; +import type { Json, JsonObject } from "@liveblocks/core"; import type { HeadersInit } from "./compat.js"; import { HttpError, ValidationError } from "./HttpError.js"; @@ -20,6 +20,7 @@ const KB = 1024; * But not supported yet in: * - Bun - see https://github.com/oven-sh/bun/issues/3700 */ +/* istanbul ignore next -- @preserve polyfill only used in environments without ReadableStream.from (e.g. Bun) */ function ReadableStream_from_shim(iterable: Iterable): ReadableStream { const iterator = iterable[Symbol.iterator](); return new ReadableStream({ @@ -38,6 +39,7 @@ function ReadableStream_from_shim(iterable: Iterable): ReadableStream { } /* eslint-disable */ +/* istanbul ignore next -- @preserve only one branch reachable per environment */ const ReadableStream_from = typeof (ReadableStream as any).from === "function" ? ((ReadableStream as any).from.bind(ReadableStream) as ( @@ -77,7 +79,7 @@ export function empty(): Response { * Return a JSON response. */ export function json( - value: JSONObject, + value: JsonObject, status = 200, headers?: HeadersInit ): Response { @@ -165,7 +167,7 @@ export function textStream( * that yields JSON values. Each value will be serialized as a single line. */ export function ndjsonStream( - iterable: Iterable, + iterable: Iterable, headers?: HeadersInit ): Response { const lines = imap(iterable, (value) => `${JSON.stringify(value)}\n`); @@ -180,7 +182,7 @@ export function ndjsonStream( * values. The output will be a valid JSON array: [value1,value2,...]\n */ export function jsonArrayStream( - iterable: Iterable, + iterable: Iterable, headers?: HeadersInit ): Response { function* chunks() { diff --git a/packages/liveblocks-zenrouter/test/ErrorHandler.test.ts b/packages/liveblocks-zenrouter/test/ErrorHandler.test.ts index a8fa1f39331..156f3234c1a 100644 --- a/packages/liveblocks-zenrouter/test/ErrorHandler.test.ts +++ b/packages/liveblocks-zenrouter/test/ErrorHandler.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test, vi } from "vitest"; import type { ErrorContext } from "~/ErrorHandler.js"; import { ErrorHandler } from "~/ErrorHandler.js"; -import { HttpError } from "~/index.js"; +import { abort, HttpError } from "~/index.js"; import { captureConsole, @@ -139,6 +139,23 @@ describe("ErrorHandler", () => { ); }); + test("handles generic abort with status code unsupported by HttpError", async () => { + const eh = new ErrorHandler(); + + // abort(500) creates a generic abort Response. When the handler tries to + // convert it to an HttpError, the constructor throws because 5xx is not + // allowed. The catch fallback returns a generic error response. + let abortResponse: Response; + try { + abort(500); + } catch (e) { + abortResponse = e as Response; + } + + const res = await eh.handle(abortResponse!, unused); + await expectResponse(res, { error: "Unknown" }, 500); + }); + test("handles bugs in error handler itself", async () => { const konsole = captureConsole(); diff --git a/packages/liveblocks-zenrouter/test/Router.test.ts b/packages/liveblocks-zenrouter/test/Router.test.ts index d887238860a..01d0ce05dc9 100644 --- a/packages/liveblocks-zenrouter/test/Router.test.ts +++ b/packages/liveblocks-zenrouter/test/Router.test.ts @@ -423,7 +423,7 @@ describe("Router body validation", () => { expect(konsole.error).toHaveBeenCalledWith( expect.stringMatching( - /^Uncaught error: Error: Cannot access body: this endpoint did not define a body decoder/ + /^Uncaught error: Error: Cannot access body: this endpoint did not define a body schema/ ) ); }); diff --git a/packages/liveblocks-zenrouter/test/Router.zod.test.ts b/packages/liveblocks-zenrouter/test/Router.zod.test.ts new file mode 100644 index 00000000000..8f98b6befa3 --- /dev/null +++ b/packages/liveblocks-zenrouter/test/Router.zod.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, test } from "vitest"; +import { z } from "zod"; + +import { json, ZenRouter } from "~/index.js"; +import { expectResponse } from "~test/utils.js"; + +const IGNORE_AUTH_FOR_THIS_TEST = () => Promise.resolve(true); + +describe("Param decoders (zod)", () => { + // Zod equivalent of decoders' `numeric`: coerce a string input to a number + const zodNumeric = z.coerce.number(); + + const r = new ZenRouter({ + authorize: IGNORE_AUTH_FOR_THIS_TEST, + params: { + x: zodNumeric, + y: zodNumeric, + }, + }); + + r.route("GET /add//", ({ p }) => json({ result: p.x + p.y, p })); + r.route("GET /echo/", ({ p }) => json({ name: p.name })); + + test("placeholders are automatically decoded + validated", async () => { + const req = new Request("http://example.org/add/1337/42"); + expect(await (await r.fetch(req)).json()).toEqual({ + result: 1379, + p: { x: 1337, y: 42 }, + }); + }); + + test("placeholders that cannot be decoded/transformed will throw a 400 error", async () => { + const req = new Request("http://example.org/add/1/one"); + const resp = await r.fetch(req); + await expectResponse(resp, { error: "Bad Request" }, 400); + }); + + test("untyped placeholders still work as strings", async () => { + const req = new Request("http://example.org/echo/hello"); + expect(await (await r.fetch(req)).json()).toEqual({ name: "hello" }); + }); +}); + +describe("Router body validation (zod)", () => { + const r = new ZenRouter({ authorize: IGNORE_AUTH_FOR_THIS_TEST }); + + r.route( + "POST /add", + z.object({ x: z.number(), y: z.number() }), + ({ body }) => ({ result: body.x + body.y }) + ); + + r.route( + "POST /empty", + z.object({ a: z.number() }).optional(), + () => ({ ok: true }) + ); + + test("accepts correct body", async () => { + const req = new Request("http://example.org/add", { + method: "POST", + body: '{"x":41,"y":1}', + }); + await expectResponse(await r.fetch(req), { result: 42 }); + }); + + test("rejects invalid body with actual error message", async () => { + const req = new Request("http://example.org/add", { + method: "POST", + body: '{"x":41,"y":"not a number"}', + }); + await expectResponse( + await r.fetch(req), + { + error: "Unprocessable Entity", + reason: + "Value at key 'y': Invalid input: expected number, received string", + }, + 422 + ); + }); + + test("rejects body with missing fields", async () => { + const req = new Request("http://example.org/add", { + method: "POST", + body: '{"x":41}', + }); + await expectResponse( + await r.fetch(req), + { + error: "Unprocessable Entity", + reason: + "Value at key 'y': Invalid input: expected number, received undefined", + }, + 422 + ); + }); + + test("broken JSON bodies lead to 400", async () => { + const req = new Request("http://example.org/add", { + method: "POST", + body: "I'm no JSON", + }); + await expectResponse(await r.fetch(req), { error: "Bad Request" }, 400); + }); + + test("can accept empty bodies", async () => { + { + const req = new Request("http://example.org/empty", { + method: "POST", + body: "nah-ah", // Invalid body + }); + await expectResponse(await r.fetch(req), { error: "Bad Request" }, 400); + } + + { + const req = new Request("http://example.org/empty", { + method: "POST", + body: '{"a": 123}', // Valid body + }); + await expectResponse(await r.fetch(req), { ok: true }); + } + + { + const req = new Request("http://example.org/empty", { + method: "POST", + // No body here + }); + await expectResponse(await r.fetch(req), { ok: true }); + } + }); +}); diff --git a/packages/liveblocks-zenrouter/test/streams.test.ts b/packages/liveblocks-zenrouter/test/streams.test.ts index 4d9ef7de63a..32fdcca8f46 100644 --- a/packages/liveblocks-zenrouter/test/streams.test.ts +++ b/packages/liveblocks-zenrouter/test/streams.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { + html, jsonArrayStream, ndjsonStream, textStream, @@ -18,6 +19,27 @@ async function readStream(response: Response): Promise { return result; } +describe("html", () => { + test("returns html response with correct content-type", async () => { + const response = html("

Hello

"); + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe( + "text/html; charset=utf-8" + ); + expect(await response.text()).toBe("

Hello

"); + }); + + test("accepts custom status and headers", async () => { + const response = html("

Not Found

", 404, { "X-Custom": "value" }); + expect(response.status).toBe(404); + expect(response.headers.get("Content-Type")).toBe( + "text/html; charset=utf-8" + ); + expect(response.headers.get("X-Custom")).toBe("value"); + expect(await response.text()).toBe("

Not Found

"); + }); +}); + describe("textStream", () => { test("streams from iterable", async () => { function* chunks() { @@ -36,6 +58,17 @@ describe("textStream", () => { expect(response.headers.get("X-Custom")).toBe("value"); }); + test("flushes buffer when it exceeds bufSize", async () => { + // With bufSize=5, "hello" (5 chars) triggers a flush, then " world" (6 chars) triggers another + function* chunks() { + yield "hello"; + yield " world"; + } + + const response = textStream(chunks(), undefined, { bufSize: 5 }); + expect(await response.text()).toBe("hello world"); + }); + test("handles empty iterable", async () => { const response = textStream([]); expect(await readStream(response)).toBe(""); diff --git a/starter-kits/nextjs-starter-kit/app/Providers.tsx b/starter-kits/nextjs-starter-kit/app/Providers.tsx index a226973dc9f..6f2d9e811a4 100644 --- a/starter-kits/nextjs-starter-kit/app/Providers.tsx +++ b/starter-kits/nextjs-starter-kit/app/Providers.tsx @@ -6,18 +6,14 @@ import { TooltipProvider } from "@radix-ui/react-tooltip"; import Router from "next/router"; import { Session } from "next-auth"; import { SessionProvider } from "next-auth/react"; -import { ReactNode, useEffect } from "react"; +import { ReactNode } from "react"; import { DOCUMENT_URL } from "@/constants"; import { authorizeLiveblocks, getLiveUsers, getSpecificDocuments, } from "@/lib/actions"; -import { syncLiveblocksGroups } from "@/lib/actions/syncLiveblocksGroups"; -import { getGroups, getUsers } from "@/lib/database"; - -const SYNC_LIVEBLOCKS_GROUPS_KEY = "sync-liveblocks-groups"; -const SYNC_LIVEBLOCKS_GROUPS_VALUE = "1"; +import { getUsers } from "@/lib/database"; export function Providers({ children, @@ -26,30 +22,6 @@ export function Providers({ children: ReactNode; session: Session | null; }) { - // Sync the starter kit's groups with Liveblocks once per - // session and only during development - useEffect(() => { - if (process.env.NODE_ENV !== "development") { - return; - } - - if ( - sessionStorage.getItem(SYNC_LIVEBLOCKS_GROUPS_KEY) === - SYNC_LIVEBLOCKS_GROUPS_VALUE - ) { - return; - } - - sessionStorage.setItem( - SYNC_LIVEBLOCKS_GROUPS_KEY, - SYNC_LIVEBLOCKS_GROUPS_VALUE - ); - - syncLiveblocksGroups().catch(() => { - sessionStorage.removeItem(SYNC_LIVEBLOCKS_GROUPS_KEY); - }); - }, []); - return ( { - const { data, error } = await authorizeLiveblocks(); + authEndpoint={async (roomId) => { + const { data, error } = await authorizeLiveblocks(roomId); if (error) { Router.push({ @@ -82,30 +54,23 @@ export function Providers({ }} // Resolve group IDs into name/avatar/etc for Comments resolveGroupsInfo={async ({ groupIds }) => { - const groups = await getGroups({ groupIds }); - return groups.map((group) => group ?? undefined); - }} - // Resolve a room ID into room information for Notifications - resolveRoomsInfo={async ({ roomIds }) => { - const documents = await getSpecificDocuments({ - documentIds: roomIds, - }); - return documents.map((document) => ({ - name: document ? document.name : undefined, - url: document - ? DOCUMENT_URL(document.type, document.id) - : undefined, + // Not expected in current app, only @here an @everyone added + if (!groupIds.includes("everyone") || !groupIds.includes("here")) { + return []; + } + + return groupIds.map((groupId) => ({ + id: groupId, + name: groupId.charAt(0).toUpperCase() + groupId.slice(1), })); }} // Resolve what mentions are suggested for Comments/Text Editor resolveMentionSuggestions={async ({ text, roomId }) => { - const [allUsers, liveUsers, matchingGroups, matchingUsers] = - await Promise.all([ - getUsers(), // All users - getLiveUsers({ documentIds: [roomId] }), // All users currently online in the document - getGroups({ search: text }), // Groups that match the search term - getUsers({ search: text }), // Users that match the search term - ]); + const [allUsers, liveUsers, matchingUsers] = await Promise.all([ + getUsers(), // All users + getLiveUsers({ documentIds: [roomId] }), // All users currently online in the document + getUsers({ search: text }), // Users that match the search term + ]); const globalSuggestions: MentionData[] = []; @@ -131,14 +96,6 @@ export function Providers({ }); } - // Create group suggestions, e.g. `@engineering` - const groupSuggestions: MentionData[] = matchingGroups - .filter((group) => group !== null) - .map((group) => ({ - kind: "group", - id: group.id, - })); - // Create user suggestions, e.g. `anjali.wanda@example.com` const userSuggestions: MentionData[] = matchingUsers .filter((user) => user !== null) @@ -148,11 +105,19 @@ export function Providers({ })); // Return combined suggestions - return [ - ...globalSuggestions, - ...groupSuggestions, - ...userSuggestions, - ]; + return [...globalSuggestions, ...userSuggestions]; + }} + // Resolve a room ID into room information for Notifications + resolveRoomsInfo={async ({ roomIds }) => { + const documents = await getSpecificDocuments({ + documentIds: roomIds, + }); + return documents.map((document) => ({ + name: document ? document.name : undefined, + url: document + ? DOCUMENT_URL(document.type, document.id) + : undefined, + })); }} > {children} diff --git a/starter-kits/nextjs-starter-kit/app/canvas/[id]/CanvasDocumentView.tsx b/starter-kits/nextjs-starter-kit/app/canvas/[id]/CanvasDocumentView.tsx index a8d991b87dc..564687e0eee 100644 --- a/starter-kits/nextjs-starter-kit/app/canvas/[id]/CanvasDocumentView.tsx +++ b/starter-kits/nextjs-starter-kit/app/canvas/[id]/CanvasDocumentView.tsx @@ -3,7 +3,7 @@ import { useParams } from "next/navigation"; import { useEffect, useState } from "react"; import { Canvas } from "@/components/Canvas"; -import { DocumentHeader, DocumentHeaderSkeleton } from "@/components/Document"; +import { Header, HeaderSkeleton } from "@/components/Header"; import { DocumentLayout, DocumentProviders } from "@/layouts/Document"; import { ErrorLayout } from "@/layouts/Error"; import { Document, ErrorData } from "@/types"; @@ -29,14 +29,12 @@ export function CanvasDocumentView({ initialDocument, initialError }: Props) { } if (!initialDocument) { - return } />; + return } />; } return ( - } - > + }> diff --git a/starter-kits/nextjs-starter-kit/app/dashboard/drafts/page.tsx b/starter-kits/nextjs-starter-kit/app/dashboard/drafts/page.tsx deleted file mode 100644 index ebea7fd409f..00000000000 --- a/starter-kits/nextjs-starter-kit/app/dashboard/drafts/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { DocumentsLayout } from "@/layouts/Documents"; - -export default async function DashboardDraftsPage() { - return ; -} diff --git a/starter-kits/nextjs-starter-kit/app/dashboard/group/[groupId]/page.tsx b/starter-kits/nextjs-starter-kit/app/dashboard/group/[groupId]/page.tsx deleted file mode 100644 index 86c070d07f1..00000000000 --- a/starter-kits/nextjs-starter-kit/app/dashboard/group/[groupId]/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { DocumentsLayout } from "@/layouts/Documents"; - -type Props = { - params: Promise<{ groupId: string }>; -}; - -export default async function DashboardGroupPage(props: Props) { - const params = await props.params; - return ; -} diff --git a/starter-kits/nextjs-starter-kit/app/dashboard/layout.tsx b/starter-kits/nextjs-starter-kit/app/dashboard/layout.tsx index 9c58bab52c8..d4c6c7dda8b 100644 --- a/starter-kits/nextjs-starter-kit/app/dashboard/layout.tsx +++ b/starter-kits/nextjs-starter-kit/app/dashboard/layout.tsx @@ -2,7 +2,6 @@ import { redirect } from "next/navigation"; import { ReactNode } from "react"; import { auth } from "@/auth"; import { DashboardLayout } from "@/layouts/Dashboard"; -import { getGroups } from "@/lib/actions"; export default async function Dashboard({ children }: { children: ReactNode }) { const session = await auth(); @@ -12,7 +11,5 @@ export default async function Dashboard({ children }: { children: ReactNode }) { redirect("/"); } - const groups = await getGroups(session?.user.info.groupIds ?? []); - - return {children}; + return {children}; } diff --git a/starter-kits/nextjs-starter-kit/app/dashboard/organization/page.tsx b/starter-kits/nextjs-starter-kit/app/dashboard/organization/page.tsx new file mode 100644 index 00000000000..ff8237e97f9 --- /dev/null +++ b/starter-kits/nextjs-starter-kit/app/dashboard/organization/page.tsx @@ -0,0 +1,5 @@ +import { DocumentsList } from "@/components/DocumentsList"; + +export default async function DashboardPage() { + return ; +} diff --git a/starter-kits/nextjs-starter-kit/app/dashboard/page.tsx b/starter-kits/nextjs-starter-kit/app/dashboard/page.tsx index dc37a350880..b5e33d8aee6 100644 --- a/starter-kits/nextjs-starter-kit/app/dashboard/page.tsx +++ b/starter-kits/nextjs-starter-kit/app/dashboard/page.tsx @@ -1,5 +1,5 @@ -import { DocumentsLayout } from "@/layouts/Documents"; +import { DocumentsList } from "@/components/DocumentsList"; export default async function DashboardPage() { - return ; + return ; } diff --git a/starter-kits/nextjs-starter-kit/app/dashboard/private/page.tsx b/starter-kits/nextjs-starter-kit/app/dashboard/private/page.tsx new file mode 100644 index 00000000000..09da09e3251 --- /dev/null +++ b/starter-kits/nextjs-starter-kit/app/dashboard/private/page.tsx @@ -0,0 +1,5 @@ +import { DocumentsList } from "@/components/DocumentsList"; + +export default async function DashboardPage() { + return ; +} diff --git a/starter-kits/nextjs-starter-kit/app/dashboard/public/page.tsx b/starter-kits/nextjs-starter-kit/app/dashboard/public/page.tsx new file mode 100644 index 00000000000..8ab36196df7 --- /dev/null +++ b/starter-kits/nextjs-starter-kit/app/dashboard/public/page.tsx @@ -0,0 +1,5 @@ +import { DocumentsList } from "@/components/DocumentsList"; + +export default async function DashboardPage() { + return ; +} diff --git a/starter-kits/nextjs-starter-kit/app/note/[id]/NoteDocumentView.tsx b/starter-kits/nextjs-starter-kit/app/note/[id]/NoteDocumentView.tsx index 1d402a01da6..c91df12d1b3 100644 --- a/starter-kits/nextjs-starter-kit/app/note/[id]/NoteDocumentView.tsx +++ b/starter-kits/nextjs-starter-kit/app/note/[id]/NoteDocumentView.tsx @@ -2,7 +2,7 @@ import { useParams } from "next/navigation"; import { useEffect, useState } from "react"; -import { DocumentHeader, DocumentHeaderSkeleton } from "@/components/Document"; +import { Header, HeaderSkeleton } from "@/components/Header"; import { NoteEditor } from "@/components/NoteEditor"; import { DocumentLayout, DocumentProviders } from "@/layouts/Document"; import { ErrorLayout } from "@/layouts/Error"; @@ -29,15 +29,13 @@ export function NoteDocumentView({ initialDocument, initialError }: Props) { } if (!initialDocument) { - return } />; + return } />; } return ( - } + header={
} > diff --git a/starter-kits/nextjs-starter-kit/app/text/[id]/TextDocumentView.tsx b/starter-kits/nextjs-starter-kit/app/text/[id]/TextDocumentView.tsx index f6da2dcc260..0f535ca7129 100644 --- a/starter-kits/nextjs-starter-kit/app/text/[id]/TextDocumentView.tsx +++ b/starter-kits/nextjs-starter-kit/app/text/[id]/TextDocumentView.tsx @@ -2,7 +2,7 @@ import { useParams } from "next/navigation"; import { useEffect, useState } from "react"; -import { DocumentHeader, DocumentHeaderSkeleton } from "@/components/Document"; +import { Header, HeaderSkeleton } from "@/components/Header"; import { TextEditor } from "@/components/TextEditor"; import { DocumentLayout, DocumentProviders } from "@/layouts/Document"; import { ErrorLayout } from "@/layouts/Error"; @@ -29,14 +29,12 @@ export function TextDocumentView({ initialDocument, initialError }: Props) { } if (!initialDocument) { - return } />; + return } />; } return ( - } - > + }> diff --git a/starter-kits/nextjs-starter-kit/app/whiteboard/[id]/WhiteboardDocumentView.tsx b/starter-kits/nextjs-starter-kit/app/whiteboard/[id]/WhiteboardDocumentView.tsx index 58b27758590..04ef89dbf99 100644 --- a/starter-kits/nextjs-starter-kit/app/whiteboard/[id]/WhiteboardDocumentView.tsx +++ b/starter-kits/nextjs-starter-kit/app/whiteboard/[id]/WhiteboardDocumentView.tsx @@ -2,7 +2,7 @@ import { useParams } from "next/navigation"; import { useEffect, useState } from "react"; -import { DocumentHeader, DocumentHeaderSkeleton } from "@/components/Document"; +import { Header, HeaderSkeleton } from "@/components/Header"; import { Whiteboard } from "@/components/Whiteboard"; import { DocumentLayout, DocumentProviders } from "@/layouts/Document"; import { ErrorLayout } from "@/layouts/Error"; @@ -32,14 +32,12 @@ export function WhiteboardDocumentView({ } if (!initialDocument) { - return } />; + return } />; } return ( - } - > + }> diff --git a/starter-kits/nextjs-starter-kit/auth.ts b/starter-kits/nextjs-starter-kit/auth.ts index b052e509dbe..f9c072050c3 100644 --- a/starter-kits/nextjs-starter-kit/auth.ts +++ b/starter-kits/nextjs-starter-kit/auth.ts @@ -1,5 +1,6 @@ import NextAuth from "next-auth"; import { authConfig } from "@/auth.config"; +import { getCurrentOrganization, switchOrganization } from "@/lib/actions"; import { getUser } from "@/lib/database/getUser"; // Your NextAuth secret (generate a new one for production) @@ -19,7 +20,7 @@ export const { callbacks: { // Get extra user info from your database to pass to front-end // For front end, update next-auth.d.ts with session type - async session({ session }: { session: any }) { + async session({ session, token }: { session: any; token: any }) { const userInfo = await getUser(session.user.email); if (!userInfo) { @@ -27,8 +28,42 @@ export const { } session.user.info = userInfo; + session.user.currentOrganizationId = token.organizationId; + + // On every load, check current org and if user has access + const currentOrganizationId = await getCurrentOrganization(); + + if (currentOrganizationId) { + if (userInfo.organizationIds.includes(currentOrganizationId)) { + session.user.currentOrganizationId = currentOrganizationId; + } + } + return session; }, + async jwt({ token, user }) { + // Once on sign in, get user and current org. Set an org if not set already. + if (user && user.email) { + const userInfo = await getUser(user.email); + + if (userInfo) { + const currentOrganizationId = await getCurrentOrganization(); + + if ( + currentOrganizationId && + userInfo.organizationIds.includes(currentOrganizationId) + ) { + token.organizationId = currentOrganizationId; + } else { + const firstOrganization = userInfo.organizationIds[0]; + token.organizationId = firstOrganization; + await switchOrganization(firstOrganization); + } + } + } + + return token; + }, }, pages: { signIn: "/signin", diff --git a/starter-kits/nextjs-starter-kit/components/Dashboard/DashboardHeader.module.css b/starter-kits/nextjs-starter-kit/components/Dashboard/DashboardHeader.module.css deleted file mode 100644 index 37b711bb10f..00000000000 --- a/starter-kits/nextjs-starter-kit/components/Dashboard/DashboardHeader.module.css +++ /dev/null @@ -1,113 +0,0 @@ -.header { - display: grid; - width: 100%; - height: 100%; - padding: 0 var(--space-8); - grid-template-columns: 1fr 2fr 1fr; -} - -.menu, -.logo, -.profile { - display: flex; - place-items: center; -} - -.menu { - place-content: flex-start; -} - -.menuToggle { - display: flex; - width: 32px; - height: 32px; - background: var(--color-surface); - border-radius: 100%; - place-content: center; - place-items: center; -} - -.logo { - place-content: center; -} - -.logoLink { - transition: opacity var(--transition); - outline: none; -} - -.logoLink:hover, -.logoLink:focus-visible { - opacity: var(--opacity-hover); -} - -.profile { - gap: var(--space-6); - place-content: flex-end; -} - -.profileButton { - transition: opacity var(--transition); - outline: none; -} - -.profileButton:hover, -.profileButton:focus-visible { - opacity: var(--opacity-hover); -} - -.profilePopover { - display: flex; - min-width: 180px; - max-width: 320px; - flex-direction: column; -} - -.profilePopoverInfo { - padding: var(--space-6) var(--space-7); -} - -.profilePopoverActions { - padding: var(--space-3); -} - -.profilePopoverInfo { - display: flex; - flex-direction: column; - border-bottom: 1px solid var(--color-border); -} - -.profilePopoverName, -.profilePopoverId { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.profilePopoverName { - font-weight: 500; -} - -.profilePopoverId { - color: var(--color-text-lighter); - font-size: var(--size-sm); -} - -.profilePopoverButton { - width: 100%; - place-content: center; -} - -@media (min-width: 1024px) { - .header { - grid-template-columns: repeat(2, 1fr); - } - - .menu { - display: none; - } - - .logo { - place-content: flex-start; - } -} diff --git a/starter-kits/nextjs-starter-kit/components/Dashboard/DashboardHeader.tsx b/starter-kits/nextjs-starter-kit/components/Dashboard/DashboardHeader.tsx deleted file mode 100644 index c2fa5e977f8..00000000000 --- a/starter-kits/nextjs-starter-kit/components/Dashboard/DashboardHeader.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import clsx from "clsx"; -import Link from "next/link"; -import { signOut, useSession } from "next-auth/react"; -import { ComponentProps, MouseEventHandler } from "react"; -import { CrossIcon, MenuIcon, SignOutIcon } from "@/icons"; -import { Avatar } from "@/primitives/Avatar"; -import { Button } from "@/primitives/Button"; -import { Popover } from "@/primitives/Popover"; -import { InboxPopover } from "../Inbox"; -import { Logo } from "../Logo"; -import styles from "./DashboardHeader.module.css"; - -interface Props extends ComponentProps<"header"> { - isOpen: boolean; - onMenuClick: MouseEventHandler; -} - -export function DashboardHeader({ - isOpen, - onMenuClick, - className, - ...props -}: Props) { - const { data: session } = useSession(); - - return ( -
-
- -
-
- - - -
-
- {session && ( - -
- - {session.user.info.name} - - - {session.user.info.id} - -
-
- -
-
- } - side="bottom" - sideOffset={6} - > - - - )} -
- -
- -
- ); -} diff --git a/starter-kits/nextjs-starter-kit/components/Dashboard/DashboardSidebar.tsx b/starter-kits/nextjs-starter-kit/components/Dashboard/DashboardSidebar.tsx index 5eb4a1b2a9d..d3fe6ded92c 100644 --- a/starter-kits/nextjs-starter-kit/components/Dashboard/DashboardSidebar.tsx +++ b/starter-kits/nextjs-starter-kit/components/Dashboard/DashboardSidebar.tsx @@ -1,20 +1,22 @@ +"use client"; + import clsx from "clsx"; +import Image from "next/image"; import { usePathname } from "next/navigation"; import { ComponentProps, useMemo } from "react"; import { - DASHBOARD_DRAFTS_URL, - DASHBOARD_GROUP_URL, + DASHBOARD_ORGANIZATION_URL, + DASHBOARD_PRIVATE_URL, + DASHBOARD_PUBLIC_URL, DASHBOARD_URL, } from "@/constants"; -import { FileIcon, FolderIcon } from "@/icons"; +import { EarthIcon, FileIcon, LockIcon } from "@/icons"; +import { useCurrentOrganization } from "@/lib/hooks"; import { LinkButton } from "@/primitives/Button"; -import { Group } from "@/types"; import { normalizeTrailingSlash } from "@/utils"; import styles from "./DashboardSidebar.module.css"; -interface Props extends ComponentProps<"div"> { - groups: Group[]; -} +interface Props extends ComponentProps<"div"> {} interface SidebarLinkProps extends Omit< ComponentProps, @@ -48,41 +50,67 @@ function SidebarLink({ ); } -export function DashboardSidebar({ className, groups, ...props }: Props) { +const ICON_SIZE = 18; + +export function DashboardSidebar({ className, ...props }: Props) { + const { currentOrganization } = useCurrentOrganization(); return (
); diff --git a/starter-kits/nextjs-starter-kit/components/Dashboard/index.ts b/starter-kits/nextjs-starter-kit/components/Dashboard/index.ts index 4fda7973b73..b14d912993f 100644 --- a/starter-kits/nextjs-starter-kit/components/Dashboard/index.ts +++ b/starter-kits/nextjs-starter-kit/components/Dashboard/index.ts @@ -1,2 +1 @@ export * from "./DashboardSidebar"; -export * from "./DashboardHeader"; diff --git a/starter-kits/nextjs-starter-kit/components/Document/DocumentHeader.tsx b/starter-kits/nextjs-starter-kit/components/Document/DocumentHeader.tsx deleted file mode 100644 index 3febd357d1a..00000000000 --- a/starter-kits/nextjs-starter-kit/components/Document/DocumentHeader.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { ClientSideSuspense } from "@liveblocks/react"; -import clsx from "clsx"; -import Link from "next/link"; -import { ComponentProps } from "react"; -import { InboxPopover } from "@/components/Inbox"; -import { ShareIcon } from "@/icons"; -import { renameDocument } from "@/lib/actions"; -import { useInitialDocument } from "@/lib/hooks"; -import { Button } from "@/primitives/Button"; -import { Skeleton } from "@/primitives/Skeleton"; -import { Document } from "@/types"; -import { Logo } from "../Logo"; -import { ShareDialog } from "../ShareDialog"; -import { DocumentHeaderAvatars } from "./DocumentHeaderAvatars"; -import { DocumentHeaderName } from "./DocumentHeaderName"; -import styles from "./DocumentHeader.module.css"; - -interface Props extends ComponentProps<"header"> { - documentId: Document["id"]; - showTitle?: boolean; -} - -export function DocumentHeader({ - documentId, - showTitle = true, - className, - ...props -}: Props) { - const initialDocument = useInitialDocument(); - - return ( -
-
- - - -
-
- {showTitle ? ( - - {initialDocument.name} - - } - > - renameDocument({ documentId, name })} - /> - - ) : null} -
-
-
- - - -
- } disabled={true}> - Share - - } - > - - - - - - -
-
- ); -} - -export function DocumentHeaderSkeleton({ - className, - ...props -}: ComponentProps<"header">) { - return ( -
-
- - - -
-
- -
-
- ); -} diff --git a/starter-kits/nextjs-starter-kit/components/Document/DocumentHeaderAvatars.tsx b/starter-kits/nextjs-starter-kit/components/Document/DocumentHeaderAvatars.tsx deleted file mode 100644 index 6b197f7e263..00000000000 --- a/starter-kits/nextjs-starter-kit/components/Document/DocumentHeaderAvatars.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useOthers, useSelf } from "@liveblocks/react/suspense"; -import { useMemo } from "react"; -import { AvatarStack } from "@/primitives/AvatarStack"; - -export function DocumentHeaderAvatars() { - const self = useSelf(); - const others = useOthers(); - const users = useMemo( - () => (self ? [self, ...others] : others), - [self, others] - ); - - return ( - ({ - name: user.info.name, - src: user.info.avatar, - color: user.info.color, - }))} - max={5} - size={20} - tooltip - tooltipProps={{ sideOffset: 28 }} - /> - ); -} diff --git a/starter-kits/nextjs-starter-kit/components/Document/index.ts b/starter-kits/nextjs-starter-kit/components/Document/index.ts deleted file mode 100644 index 9071280d17b..00000000000 --- a/starter-kits/nextjs-starter-kit/components/Document/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./DocumentHeader"; diff --git a/starter-kits/nextjs-starter-kit/components/Documents/DocumentCreatePopover.module.css b/starter-kits/nextjs-starter-kit/components/DocumentsList/DocumentCreatePopover.module.css similarity index 100% rename from starter-kits/nextjs-starter-kit/components/Documents/DocumentCreatePopover.module.css rename to starter-kits/nextjs-starter-kit/components/DocumentsList/DocumentCreatePopover.module.css diff --git a/starter-kits/nextjs-starter-kit/components/Documents/DocumentCreatePopover.tsx b/starter-kits/nextjs-starter-kit/components/DocumentsList/DocumentCreatePopover.tsx similarity index 87% rename from starter-kits/nextjs-starter-kit/components/Documents/DocumentCreatePopover.tsx rename to starter-kits/nextjs-starter-kit/components/DocumentsList/DocumentCreatePopover.tsx index 64af4c6fc76..0bcd1621c47 100644 --- a/starter-kits/nextjs-starter-kit/components/Documents/DocumentCreatePopover.tsx +++ b/starter-kits/nextjs-starter-kit/components/DocumentsList/DocumentCreatePopover.tsx @@ -1,25 +1,19 @@ +"use client"; + import { ComponentProps, useState } from "react"; import { PlusIcon } from "@/icons"; import { createDocument } from "@/lib/actions"; import { Button } from "@/primitives/Button"; import { Popover } from "@/primitives/Popover"; -import { Document, DocumentGroup, DocumentType, DocumentUser } from "@/types"; +import { Document, DocumentType, DocumentUser } from "@/types"; import styles from "./DocumentCreatePopover.module.css"; interface Props extends Omit, "content"> { documentName?: Document["name"]; - draft: Document["draft"]; - groupIds?: DocumentGroup["id"][]; userId: DocumentUser["id"]; } -export function DocumentCreatePopover({ - groupIds, - userId, - draft, - children, - ...props -}: Props) { +export function DocumentCreatePopover({ userId, children, ...props }: Props) { const [disableButtons, setDisableButtons] = useState(false); // Create a new document, then navigate to the document's URL location @@ -30,8 +24,6 @@ export function DocumentCreatePopover({ name, type, userId, - draft, - groupIds: draft ? undefined : groupIds, }, true ); diff --git a/starter-kits/nextjs-starter-kit/components/Documents/DocumentDeleteDialog.module.css b/starter-kits/nextjs-starter-kit/components/DocumentsList/DocumentDeleteDialog.module.css similarity index 100% rename from starter-kits/nextjs-starter-kit/components/Documents/DocumentDeleteDialog.module.css rename to starter-kits/nextjs-starter-kit/components/DocumentsList/DocumentDeleteDialog.module.css diff --git a/starter-kits/nextjs-starter-kit/components/Documents/DocumentDeleteDialog.tsx b/starter-kits/nextjs-starter-kit/components/DocumentsList/DocumentDeleteDialog.tsx similarity index 100% rename from starter-kits/nextjs-starter-kit/components/Documents/DocumentDeleteDialog.tsx rename to starter-kits/nextjs-starter-kit/components/DocumentsList/DocumentDeleteDialog.tsx diff --git a/starter-kits/nextjs-starter-kit/components/Documents/DocumentIcon.tsx b/starter-kits/nextjs-starter-kit/components/DocumentsList/DocumentIcon.tsx similarity index 100% rename from starter-kits/nextjs-starter-kit/components/Documents/DocumentIcon.tsx rename to starter-kits/nextjs-starter-kit/components/DocumentsList/DocumentIcon.tsx diff --git a/starter-kits/nextjs-starter-kit/components/Documents/DocumentRow.module.css b/starter-kits/nextjs-starter-kit/components/DocumentsList/DocumentRow.module.css similarity index 95% rename from starter-kits/nextjs-starter-kit/components/Documents/DocumentRow.module.css rename to starter-kits/nextjs-starter-kit/components/DocumentsList/DocumentRow.module.css index e22c9c4c045..f1be21aa71c 100644 --- a/starter-kits/nextjs-starter-kit/components/Documents/DocumentRow.module.css +++ b/starter-kits/nextjs-starter-kit/components/DocumentsList/DocumentRow.module.css @@ -58,15 +58,10 @@ font-size: var(--size-xs); } -.groups { - display: inline-flex; - margin-left: var(--space-3); - gap: var(--space-3); -} - -.group { +.permissionsGroup { position: relative; display: inline-block; + margin-left: var(--space-3); padding: 0 var(--space-3); border-radius: 1em; color: var(--color-text-lighter); diff --git a/starter-kits/nextjs-starter-kit/components/Documents/DocumentRow.tsx b/starter-kits/nextjs-starter-kit/components/DocumentsList/DocumentRow.tsx similarity index 78% rename from starter-kits/nextjs-starter-kit/components/Documents/DocumentRow.tsx rename to starter-kits/nextjs-starter-kit/components/DocumentsList/DocumentRow.tsx index 60a9fe637e5..801455f0813 100644 --- a/starter-kits/nextjs-starter-kit/components/Documents/DocumentRow.tsx +++ b/starter-kits/nextjs-starter-kit/components/DocumentsList/DocumentRow.tsx @@ -1,24 +1,21 @@ +"use client"; + import { RoomUser } from "@liveblocks/node"; import clsx from "clsx"; import { formatDistanceToNow } from "date-fns/formatDistanceToNow"; import Link from "next/link"; import { useSession } from "next-auth/react"; -import { - ComponentProps, - useCallback, - useEffect, - useMemo, - useState, -} from "react"; +import { ComponentProps, useCallback, useEffect, useState } from "react"; import { DOCUMENT_URL } from "@/constants"; import { DeleteIcon, MoreIcon } from "@/icons"; -import { useGroupsInfo } from "@/lib/hooks"; +import { getOrganizations } from "@/lib/actions"; +import { useDocumentsFunctionSWR } from "@/lib/hooks"; import { getDocumentAccess } from "@/lib/utils"; import { AvatarStack } from "@/primitives/AvatarStack"; import { Button } from "@/primitives/Button"; import { Popover } from "@/primitives/Popover"; import { Skeleton } from "@/primitives/Skeleton"; -import { Document, DocumentAccess } from "@/types"; +import { Document, DocumentPermissionType } from "@/types"; import { DocumentDeleteDialog } from "./DocumentDeleteDialog"; import { DocumentIcon } from "./DocumentIcon"; import styles from "./DocumentRow.module.css"; @@ -36,16 +33,9 @@ export function DocumentRow({ revalidateDocuments, ...props }: Props) { - const groupIds = useMemo( - () => Object.keys(document.accesses.groups), - [document] - ); - const groups = useGroupsInfo(groupIds); - const { data: session } = useSession(); - const [currentUserAccess, setCurrentUserAccess] = useState( - DocumentAccess.NONE - ); + const [currentUserAccess, setCurrentUserAccess] = + useState("read"); // Check if current user has access to edit the room useEffect(() => { @@ -54,9 +44,8 @@ export function DocumentRow({ } const access = getDocumentAccess({ - documentAccesses: document.accesses, + document, userId: session.user.info.id, - groupIds: session.user.info.groupIds, }); setCurrentUserAccess(access); }, [session, document]); @@ -72,6 +61,15 @@ export function DocumentRow({ } }, []); + const { data: organizations } = useDocumentsFunctionSWR( + [getOrganizations, { organizationIds: [document.organization] }], + { + refreshInterval: 0, + } + ); + + const organization = organizations?.[0] ?? null; + return (
@@ -81,20 +79,24 @@ export function DocumentRow({
{document.name} - {groups.length > 0 ? ( - - {groups.map((group) => ( - - {group.name} - - ))} + {document.generalPermissions.group === "public" ? ( + Public + ) : null} + {organization && + document.generalPermissions.group === "organization" ? ( + + {organization.name} ) : null} + {document.generalPermissions.group === "private" ? ( + Private + ) : null} Edited {formatDistanceToNow(date)} ago
+ {others && (
)} - {currentUserAccess === DocumentAccess.FULL ? ( + + {currentUserAccess === "write" ? (
{ - filter?: "all" | "drafts" | "group"; - group?: Group; + filter?: "all" | "private" | "organization" | "public"; } -export function DocumentsList({ - filter = "all", - group, - className, - ...props -}: Props) { +export function DocumentsList({ filter = "all", className, ...props }: Props) { const { data: session } = useSession(); const [documentType, setDocumentType] = useState("all"); - // Return `getDocuments` params for the current filters/group + // Return `getDocuments` params for the current filters const getDocumentsOptions: GetDocumentsProps | null = useMemo(() => { if (!session) { return null; @@ -45,33 +39,13 @@ export function DocumentsList({ const currentDocumentType = documentType === "all" ? undefined : documentType; - // Get the current user's drafts - if (filter === "drafts") { - return { - documentType: currentDocumentType, - userId: session.user.info.id, - drafts: true, - limit: DOCUMENT_LOAD_LIMIT, - }; - } - - // Get the current group's documents - if (filter === "group" && group?.id) { - return { - documentType: currentDocumentType, - groupIds: [group.id], - limit: DOCUMENT_LOAD_LIMIT, - }; - } - // Get all documents for the current user return { documentType: currentDocumentType, - userId: session.user.info.id, - groupIds: session.user.info.groupIds, + permissionGroup: filter !== "all" ? filter : undefined, limit: DOCUMENT_LOAD_LIMIT, }; - }, [filter, group, session, documentType]); + }, [session, documentType, filter]); // When session is found, find pages of documents with the above document options const { @@ -112,13 +86,9 @@ export function DocumentsList({ - + ); @@ -129,9 +99,7 @@ export function DocumentsList({ {...props} >
-

- {group?.name ?? capitalize(filter)} -

+

{capitalize(filter)}

- -
- - ) : null} -
- ); -} diff --git a/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogDefault.module.css b/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogGeneral.module.css similarity index 50% rename from starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogDefault.module.css rename to starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogGeneral.module.css index 3f85dd07bec..400e8fa3973 100644 --- a/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogDefault.module.css +++ b/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogGeneral.module.css @@ -7,14 +7,7 @@ .section { display: flex; place-items: center; -} - -.sectionLabel { - display: flex; - color: var(--color-text-lighter); - cursor: pointer; - font-size: var(--size-sm); - place-items: center; + margin-bottom: calc(-1 * var(--space-3)); } .sectionLabel[data-disabled] { @@ -33,12 +26,32 @@ margin-left: auto; } -.shareLinkSection { +.sectionStart { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--space-2); +} + +.sectionIcon { + width: 36px; + height: 36px; + background-color: var(--color-surface); + border-radius: 50%; + overflow: hidden; display: flex; - gap: var(--space-8); + place-items: center; + place-content: center; +} + +.sectionIcon > img { + width: 100%; + height: 100%; + object-fit: cover; } -.shareLinkInput { - flex: 1 0 auto; - user-select: all; +.sectionIcon > svg { + width: 50%; + height: 50%; + opacity: 0.5; } diff --git a/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogGeneral.tsx b/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogGeneral.tsx new file mode 100644 index 00000000000..47b2a1edf98 --- /dev/null +++ b/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogGeneral.tsx @@ -0,0 +1,182 @@ +import clsx from "clsx"; +import Image from "next/image"; +import { ComponentProps, useEffect, useState } from "react"; +import { EarthIcon, LockIcon } from "@/icons"; +import { getOrganizations, updateGeneralAccess } from "@/lib/actions"; +import { useDocumentsFunctionSWR } from "@/lib/hooks"; +import { Select } from "@/primitives/Select"; +import { + Document, + DocumentPermissionGroup, + DocumentPermissionType, +} from "@/types"; +import styles from "./ShareDialogGeneral.module.css"; + +interface Props extends ComponentProps<"div"> { + document: Document; + fullAccess: boolean; + onSetGeneralAccess: () => void; +} + +export function ShareDialogGeneral({ + document, + fullAccess, + onSetGeneralAccess, + className, + ...props +}: Props) { + const [permissionsGroup, setPermissionsGroup] = + useState(document.generalPermissions.group); + const [permissionType, setPermissionType] = useState( + document.generalPermissions.type + ); + + // When default access changed by another connected user, update UI + useEffect(() => { + setPermissionsGroup(document.generalPermissions.group); + setPermissionType(document.generalPermissions.type); + }, [document]); + + const [isLoading, setIsLoading] = useState<"group" | "type" | null>(null); + + // Handle permission change + async function handlePermissionChange({ + group, + type, + loading, + }: { + group: DocumentPermissionGroup; + type: DocumentPermissionType; + loading: "group" | "type"; + }) { + setPermissionsGroup(group); + setPermissionType(type); + setIsLoading(loading); + + const { data, error } = await updateGeneralAccess({ + documentId: document.id, + permissionGroup: group, + permissionType: type, + }); + + setIsLoading(null); + + if (error || !data) { + // Revert on error + setPermissionsGroup(document.generalPermissions.group); + setPermissionType(document.generalPermissions.type); + return; + } + + onSetGeneralAccess(); + } + + // Get the organization this document belongs to + const { data: organizations, isLoading: isLoadingOrganizations } = + useDocumentsFunctionSWR( + [getOrganizations, { organizationIds: [document.organization] }], + { + refreshInterval: 0, + } + ); + + const documentOrganization = organizations?.[0] ?? null; + const organizationIcon = + !isLoadingOrganizations && documentOrganization ? ( + {documentOrganization?.name + ) : null; + + return ( + <> +
+
+
+
+ {permissionsGroup === "private" ? : null} + {permissionsGroup === "organization" ? organizationIcon : null} + {permissionsGroup === "public" ? : null} +
+ +
+ { + // Updates the current group's permission type + handlePermissionChange({ + group: permissionsGroup, + type: value, + loading: "type", + }); + }} + value={permissionType} + /> + ) : null} +
+
+
+ + ); +} diff --git a/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogGroups.tsx b/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogGroups.tsx deleted file mode 100644 index 318433f7321..00000000000 --- a/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogGroups.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import clsx from "clsx"; -import { ComponentProps } from "react"; -import { removeGroupAccess, updateGroupAccess } from "@/lib/actions"; -import { Select } from "@/primitives/Select"; -import { Document, DocumentAccess, DocumentGroup } from "@/types"; -import styles from "./ShareDialogRows.module.css"; - -interface Props extends ComponentProps<"div"> { - documentId: Document["id"]; - fullAccess: boolean; - groups: DocumentGroup[]; - onSetGroups: () => void; -} - -export function ShareDialogGroups({ - documentId, - fullAccess, - groups, - onSetGroups, - className, - ...props -}: Props) { - // Remove a group from a room - async function handleRemoveDocumentGroup(id: DocumentGroup["id"]) { - const { data, error } = await removeGroupAccess({ - groupId: id, - documentId: documentId, - }); - - if (error || !data) { - return; - } - - onSetGroups(); - } - - // Update a collaborator in the room using email as user id - async function handleUpdateDocumentGroup( - id: DocumentGroup["id"], - access: DocumentAccess - ) { - const { data, error } = await updateGroupAccess({ - groupId: id, - documentId: documentId, - access: access, - }); - - if (error || !data) { - return; - } - - onSetGroups(); - } - - return ( -
- {groups - ? groups.map(({ name, id, access }) => ( -
-
- {name} - {fullAccess ? ( - - ) : null} -
-
- ({ - value: groupId, - title: capitalize(groupId), - }))} - placeholder="Choose a group…" - required - disabled={isInviteLoading} - /> - - - ) : ( -
- All of your groups have already been added. -
- )} - {errorMessage && ( -
- {errorMessage} -
- )} - - ) : ( -
- You need full access to invite groups. -
- )} -
- ); -} diff --git a/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogInviteUser.tsx b/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogInviteUser.tsx index 94e8f19ad17..2bdbdeb2469 100644 --- a/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogInviteUser.tsx +++ b/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogInviteUser.tsx @@ -5,7 +5,7 @@ import { updateUserAccess } from "@/lib/actions"; import { Button } from "@/primitives/Button"; import { Input } from "@/primitives/Input"; import { Spinner } from "@/primitives/Spinner"; -import { Document, DocumentAccess, DocumentUser } from "@/types"; +import { Document, DocumentUser } from "@/types"; import styles from "./ShareDialogInvite.module.css"; interface Props extends ComponentProps<"div"> { @@ -32,7 +32,7 @@ export function ShareDialogInviteUser({ const { error } = await updateUserAccess({ userId: id, documentId: documentId, - access: DocumentAccess.READONLY, + access: "read", }); setInviteLoading(false); @@ -81,7 +81,7 @@ export function ShareDialogInviteUser({ ) : (
- You need full access to invite others. + You don’t have access to change permissions.
)}
diff --git a/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogRows.module.css b/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogRows.module.css index c09d236d19c..91b036a3ea6 100644 --- a/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogRows.module.css +++ b/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogRows.module.css @@ -13,10 +13,11 @@ display: flex; flex-direction: column; align-items: flex-start; + gap: var(--space-1); } .rowAvatar { - margin-right: var(--space-4); + margin-right: var(--space-7); } .rowName { @@ -41,7 +42,7 @@ } .rowDescription { - color: var(--color-text-lighter); + color: var(--color-text-lightest); } .rowAccessSelect { diff --git a/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogUsers.tsx b/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogUsers.tsx index ea3f823305e..bf16de22d5d 100644 --- a/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogUsers.tsx +++ b/starter-kits/nextjs-starter-kit/components/ShareDialog/ShareDialogUsers.tsx @@ -1,9 +1,9 @@ import clsx from "clsx"; -import { ComponentProps } from "react"; +import { ComponentProps, useState } from "react"; import { removeUserAccess, updateUserAccess } from "@/lib/actions"; import { Avatar } from "@/primitives/Avatar"; import { Select } from "@/primitives/Select"; -import { Document, DocumentAccess, DocumentUser } from "@/types"; +import { Document, DocumentPermissionType, DocumentUser } from "@/types"; import styles from "./ShareDialogRows.module.css"; interface Props extends ComponentProps<"div"> { @@ -23,6 +23,8 @@ export function ShareDialogUsers({ className, ...props }: Props) { + const [isLoading, setIsLoading] = useState(null); + // Remove a collaborator from the room async function handleRemoveDocumentUser(id: DocumentUser["id"]) { const { data, error } = await removeUserAccess({ @@ -40,14 +42,18 @@ export function ShareDialogUsers({ // Update a collaborator in the room using email as user id async function handleUpdateDocumentUser( id: DocumentUser["id"], - access: DocumentAccess + access: DocumentPermissionType ) { + setIsLoading(id); + const { data, error } = await updateUserAccess({ userId: id, documentId: documentId, access: access, }); + setIsLoading(null); + if (error || !data) { return; } @@ -70,7 +76,7 @@ export function ShareDialogUsers({ />
{name} - {!isCurrentUser && fullAccess ? ( + {fullAccess ? ( <> {id !== documentOwner ? (
- {!isCurrentUser && id !== documentOwner ? ( + {id !== documentOwner ? (