diff --git a/openapi-ts.config.ts b/openapi-ts.config.ts index 9fe18df..fdadc8b 100644 --- a/openapi-ts.config.ts +++ b/openapi-ts.config.ts @@ -33,6 +33,26 @@ export default defineConfig({ : undefined; }, + PersonalSession: (schema) => { + if (!schema.properties) + throw new Error("PersonalSession schema has no properties"); + + // openapi-ts doesn't like the nullable ref, so we make this a string + // instead instead of the reference to the ULID type + schema.properties["owner_user_id"] = { + description: + "The ID of the user who owns this session (if user-owned)", + type: "string", + nullable: true, + }; + schema.properties["owner_client_id"] = { + description: + "The ID of the `OAuth2` client who owns this session (if client-owned)", + type: "string", + nullable: true, + }; + }, + SiteConfig: (schema) => { // Only make the `server_name` required, rest can be optional schema.required = ["server_name"]; diff --git a/package.json b/package.json index 2e0d2a2..16eb54d 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test": "playwright test" }, "dependencies": { + "@floating-ui/react": "0.27.16", "@fontsource/inconsolata": "5.2.8", "@fontsource/inter": "5.2.8", "@formatjs/intl-localematcher": "0.6.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6dd29c4..28fb426 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@floating-ui/react': + specifier: 0.27.16 + version: 0.27.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@fontsource/inconsolata': specifier: 5.2.8 version: 5.2.8 diff --git a/src/api/mas/api/sdk.gen.ts b/src/api/mas/api/sdk.gen.ts index 864a434..d0630c3 100644 --- a/src/api/mas/api/sdk.gen.ts +++ b/src/api/mas/api/sdk.gen.ts @@ -12,6 +12,9 @@ import type { AddUserEmailResponses, AddUserRegistrationTokenData, AddUserRegistrationTokenResponses, + CreatePersonalSessionData, + CreatePersonalSessionErrors, + CreatePersonalSessionResponses, CreateUserData, CreateUserErrors, CreateUserResponses, @@ -42,6 +45,9 @@ import type { GetOAuth2SessionData, GetOAuth2SessionErrors, GetOAuth2SessionResponses, + GetPersonalSessionData, + GetPersonalSessionErrors, + GetPersonalSessionResponses, GetPolicyDataData, GetPolicyDataErrors, GetPolicyDataResponses, @@ -72,6 +78,9 @@ import type { ListOAuth2SessionsData, ListOAuth2SessionsErrors, ListOAuth2SessionsResponses, + ListPersonalSessionsData, + ListPersonalSessionsErrors, + ListPersonalSessionsResponses, ListUpstreamOAuthLinksData, ListUpstreamOAuthLinksErrors, ListUpstreamOAuthLinksResponses, @@ -93,6 +102,12 @@ import type { ReactivateUserData, ReactivateUserErrors, ReactivateUserResponses, + RegeneratePersonalSessionData, + RegeneratePersonalSessionErrors, + RegeneratePersonalSessionResponses, + RevokePersonalSessionData, + RevokePersonalSessionErrors, + RevokePersonalSessionResponses, RevokeUserRegistrationTokenData, RevokeUserRegistrationTokenErrors, RevokeUserRegistrationTokenResponses, @@ -126,6 +141,8 @@ import { vAddUserEmailResponse, vAddUserRegistrationTokenData, vAddUserRegistrationTokenResponse, + vCreatePersonalSessionData, + vCreatePersonalSessionResponse, vCreateUserData, vCreateUserResponse, vDeactivateUserData, @@ -146,6 +163,8 @@ import { vGetLatestPolicyDataResponse, vGetOAuth2SessionData, vGetOAuth2SessionResponse, + vGetPersonalSessionData, + vGetPersonalSessionResponse, vGetPolicyDataData, vGetPolicyDataResponse, vGetUpstreamOAuthLinkData, @@ -166,6 +185,8 @@ import { vListCompatSessionsResponse, vListOAuth2SessionsData, vListOAuth2SessionsResponse, + vListPersonalSessionsData, + vListPersonalSessionsResponse, vListUpstreamOAuthLinksData, vListUpstreamOAuthLinksResponse, vListUpstreamOAuthProvidersData, @@ -182,6 +203,10 @@ import { vLockUserResponse, vReactivateUserData, vReactivateUserResponse, + vRegeneratePersonalSessionData, + vRegeneratePersonalSessionResponse, + vRevokePersonalSessionData, + vRevokePersonalSessionResponse, vRevokeUserRegistrationTokenData, vRevokeUserRegistrationTokenResponse, vSetPolicyDataData, @@ -443,6 +468,157 @@ export const finishOAuth2Session = ( }); }; +/** + * List personal sessions + * Retrieve a list of personal sessions. + * Note that by default, all sessions, including revoked ones are returned, with the oldest first. + * Use the `filter[status]` parameter to filter the sessions by their status and `page[last]` parameter to retrieve the last N sessions. + */ +export const listPersonalSessions = ( + options: Options, +) => { + return options.client.get< + ListPersonalSessionsResponses, + ListPersonalSessionsErrors, + ThrowOnError + >({ + requestValidator: async (data) => { + return await v.parseAsync(vListPersonalSessionsData, data); + }, + responseValidator: async (data) => { + return await v.parseAsync(vListPersonalSessionsResponse, data); + }, + security: [ + { + scheme: "bearer", + type: "http", + }, + ], + url: "/api/admin/v1/personal-sessions", + ...options, + }); +}; + +/** + * Create a new personal session with personal access token + */ +export const createPersonalSession = ( + options: Options, +) => { + return options.client.post< + CreatePersonalSessionResponses, + CreatePersonalSessionErrors, + ThrowOnError + >({ + requestValidator: async (data) => { + return await v.parseAsync(vCreatePersonalSessionData, data); + }, + responseValidator: async (data) => { + return await v.parseAsync(vCreatePersonalSessionResponse, data); + }, + security: [ + { + scheme: "bearer", + type: "http", + }, + ], + url: "/api/admin/v1/personal-sessions", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); +}; + +/** + * Get a personal session + */ +export const getPersonalSession = ( + options: Options, +) => { + return options.client.get< + GetPersonalSessionResponses, + GetPersonalSessionErrors, + ThrowOnError + >({ + requestValidator: async (data) => { + return await v.parseAsync(vGetPersonalSessionData, data); + }, + responseValidator: async (data) => { + return await v.parseAsync(vGetPersonalSessionResponse, data); + }, + security: [ + { + scheme: "bearer", + type: "http", + }, + ], + url: "/api/admin/v1/personal-sessions/{id}", + ...options, + }); +}; + +/** + * Revoke a personal session + */ +export const revokePersonalSession = ( + options: Options, +) => { + return options.client.post< + RevokePersonalSessionResponses, + RevokePersonalSessionErrors, + ThrowOnError + >({ + requestValidator: async (data) => { + return await v.parseAsync(vRevokePersonalSessionData, data); + }, + responseValidator: async (data) => { + return await v.parseAsync(vRevokePersonalSessionResponse, data); + }, + security: [ + { + scheme: "bearer", + type: "http", + }, + ], + url: "/api/admin/v1/personal-sessions/{id}/revoke", + ...options, + }); +}; + +/** + * Regenerate a personal session by replacing its personal access token + */ +export const regeneratePersonalSession = ( + options: Options, +) => { + return options.client.post< + RegeneratePersonalSessionResponses, + RegeneratePersonalSessionErrors, + ThrowOnError + >({ + requestValidator: async (data) => { + return await v.parseAsync(vRegeneratePersonalSessionData, data); + }, + responseValidator: async (data) => { + return await v.parseAsync(vRegeneratePersonalSessionResponse, data); + }, + security: [ + { + scheme: "bearer", + type: "http", + }, + ], + url: "/api/admin/v1/personal-sessions/{id}/regenerate", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); +}; + /** * Set the current policy data */ diff --git a/src/api/mas/api/types.gen.ts b/src/api/mas/api/types.gen.ts index 08b38dd..6f403b8 100644 --- a/src/api/mas/api/types.gen.ts +++ b/src/api/mas/api/types.gen.ts @@ -343,6 +343,141 @@ export type SingleResponseForOAuth2Session = { links: SelfLinks; }; +export type PersonalSessionFilter = { + "filter[owner_user]"?: Ulid; + "filter[owner_client]"?: Ulid; + "filter[actor_user]"?: Ulid; + /** + * Retrieve the items with the given scope + */ + "filter[scope]"?: Array; + "filter[status]"?: PersonalSessionStatus; + /** + * Filter by access token expiry date + */ + "filter[expires_before]"?: string | null; + /** + * Filter by access token expiry date + */ + "filter[expires_after]"?: string | null; + /** + * Filter by whether the access token has an expiry time + */ + "filter[expires]"?: boolean | null; +}; + +export type PersonalSessionStatus = "active" | "revoked"; + +/** + * A top-level response with a page of resources + */ +export type PaginatedResponseForPersonalSession = { + meta?: PaginationMeta; + /** + * The list of resources + */ + data?: Array | null; + links: PaginationLinks; +}; + +/** + * A single resource, with its type, ID, attributes and related links + */ +export type SingleResourceForPersonalSession = { + /** + * The type of the resource + */ + type: string; + id: Ulid; + attributes: PersonalSession; + links: SelfLinks; + meta?: SingleResourceMeta; +}; + +/** + * A personal session (session using personal access tokens) + */ +export type PersonalSession = { + /** + * When the session was created + */ + created_at: string; + /** + * When the session was revoked, if applicable + */ + revoked_at?: string | null; + /** + * The ID of the user who owns this session (if user-owned) + */ + owner_user_id?: string | null; + /** + * The ID of the `OAuth2` client who owns this session (if client-owned) + */ + owner_client_id?: string | null; + actor_user_id: Ulid; + /** + * Human-readable name for the session + */ + human_name: string; + /** + * `OAuth2` scopes for this session + */ + scope: string; + /** + * When the session was last active + */ + last_active_at?: string | null; + /** + * IP address of last activity + */ + last_active_ip?: string | null; + /** + * When the current token for this session expires. The session will need to be regenerated, producing a new access token, after this time. None if the current token won't expire or if the session is revoked. + */ + expires_at?: string | null; + /** + * The actual access token (only returned on creation) + */ + access_token?: string | null; +}; + +/** + * JSON payload for the `POST /api/admin/v1/personal-sessions` endpoint + */ +export type CreatePersonalSessionRequest = { + actor_user_id: Ulid; + /** + * Human-readable name for the session + */ + human_name: string; + /** + * `OAuth2` scopes for this session + */ + scope: string; + /** + * Token expiry time in seconds. If not set, the token won't expire. + */ + expires_in?: number | null; +}; + +/** + * A top-level response with a single resource + */ +export type SingleResponseForPersonalSession = { + data: SingleResourceForPersonalSession; + links: SelfLinks; +}; + +/** + * JSON payload for the `POST /api/admin/v1/personal-sessions/{id}/regenerate` endpoint + */ +export type RegeneratePersonalSessionRequest = { + /** + * Token expiry time in seconds. If not set, the token won't expire. + */ + expires_in?: number | null; +}; + /** * JSON payload for the `POST /api/admin/v1/policy-data` */ @@ -1232,6 +1367,208 @@ export type FinishOAuth2SessionResponses = { export type FinishOAuth2SessionResponse = FinishOAuth2SessionResponses[keyof FinishOAuth2SessionResponses]; +export type ListPersonalSessionsData = { + body?: never; + path?: never; + query?: { + /** + * Retrieve the items before the given ID + */ + "page[before]"?: Ulid; + /** + * Retrieve the items after the given ID + */ + "page[after]"?: Ulid; + /** + * Retrieve the first N items + */ + "page[first]"?: number | null; + /** + * Retrieve the last N items + */ + "page[last]"?: number | null; + /** + * Include the total number of items. Defaults to `true`. + */ + count?: IncludeCount; + /** + * Filter by owner user ID + */ + "filter[owner_user]"?: Ulid; + /** + * Filter by owner `OAuth2` client ID + */ + "filter[owner_client]"?: Ulid; + /** + * Filter by actor user ID + */ + "filter[actor_user]"?: Ulid; + /** + * Retrieve the items with the given scope + */ + "filter[scope]"?: Array; + /** + * Filter by session status + */ + "filter[status]"?: PersonalSessionStatus; + /** + * Filter by access token expiry date + */ + "filter[expires_before]"?: string | null; + /** + * Filter by access token expiry date + */ + "filter[expires_after]"?: string | null; + /** + * Filter by whether the access token has an expiry time + */ + "filter[expires]"?: boolean | null; + }; + url: "/api/admin/v1/personal-sessions"; +}; + +export type ListPersonalSessionsErrors = { + /** + * Client was not found + */ + 404: ErrorResponse; +}; + +export type ListPersonalSessionsError = + ListPersonalSessionsErrors[keyof ListPersonalSessionsErrors]; + +export type ListPersonalSessionsResponses = { + /** + * Paginated response of personal sessions + */ + 200: PaginatedResponseForPersonalSession; +}; + +export type ListPersonalSessionsResponse = + ListPersonalSessionsResponses[keyof ListPersonalSessionsResponses]; + +export type CreatePersonalSessionData = { + body: CreatePersonalSessionRequest; + path?: never; + query?: never; + url: "/api/admin/v1/personal-sessions"; +}; + +export type CreatePersonalSessionErrors = { + /** + * Invalid scope provided + */ + 400: ErrorResponse; + /** + * User was not found + */ + 404: ErrorResponse; +}; + +export type CreatePersonalSessionError = + CreatePersonalSessionErrors[keyof CreatePersonalSessionErrors]; + +export type CreatePersonalSessionResponses = { + /** + * Personal session and personal access token were created + */ + 201: SingleResponseForPersonalSession; +}; + +export type CreatePersonalSessionResponse = + CreatePersonalSessionResponses[keyof CreatePersonalSessionResponses]; + +export type GetPersonalSessionData = { + body?: never; + path: { + id: Ulid; + }; + query?: never; + url: "/api/admin/v1/personal-sessions/{id}"; +}; + +export type GetPersonalSessionErrors = { + /** + * Personal session not found + */ + 404: ErrorResponse; +}; + +export type GetPersonalSessionError = + GetPersonalSessionErrors[keyof GetPersonalSessionErrors]; + +export type GetPersonalSessionResponses = { + /** + * Personal session details + */ + 200: SingleResponseForPersonalSession; +}; + +export type GetPersonalSessionResponse = + GetPersonalSessionResponses[keyof GetPersonalSessionResponses]; + +export type RevokePersonalSessionData = { + body?: never; + path: { + id: Ulid; + }; + query?: never; + url: "/api/admin/v1/personal-sessions/{id}/revoke"; +}; + +export type RevokePersonalSessionErrors = { + /** + * Personal session not found + */ + 404: ErrorResponse; + /** + * Personal session already revoked + */ + 409: ErrorResponse; +}; + +export type RevokePersonalSessionError = + RevokePersonalSessionErrors[keyof RevokePersonalSessionErrors]; + +export type RevokePersonalSessionResponses = { + /** + * Personal session was revoked + */ + 200: SingleResponseForPersonalSession; +}; + +export type RevokePersonalSessionResponse = + RevokePersonalSessionResponses[keyof RevokePersonalSessionResponses]; + +export type RegeneratePersonalSessionData = { + body: RegeneratePersonalSessionRequest; + path: { + id: Ulid; + }; + query?: never; + url: "/api/admin/v1/personal-sessions/{id}/regenerate"; +}; + +export type RegeneratePersonalSessionErrors = { + /** + * User was not found + */ + 404: ErrorResponse; +}; + +export type RegeneratePersonalSessionError = + RegeneratePersonalSessionErrors[keyof RegeneratePersonalSessionErrors]; + +export type RegeneratePersonalSessionResponses = { + /** + * Personal session was regenerated and a personal access token was created + */ + 201: SingleResponseForPersonalSession; +}; + +export type RegeneratePersonalSessionResponse = + RegeneratePersonalSessionResponses[keyof RegeneratePersonalSessionResponses]; + export type SetPolicyDataData = { body: SetPolicyDataRequest; path?: never; diff --git a/src/api/mas/api/valibot.gen.ts b/src/api/mas/api/valibot.gen.ts index 60066a5..29c094b 100644 --- a/src/api/mas/api/valibot.gen.ts +++ b/src/api/mas/api/valibot.gen.ts @@ -242,6 +242,121 @@ export const vSingleResponseForOAuth2Session = v.object({ links: vSelfLinks, }); +export const vPersonalSessionStatus = v.picklist(["active", "revoked"]); + +export const vPersonalSessionFilter = v.object({ + "filter[owner_user]": v.optional(vUlid), + "filter[owner_client]": v.optional(vUlid), + "filter[actor_user]": v.optional(vUlid), + "filter[scope]": v.optional(v.array(v.string()), []), + "filter[status]": v.optional(vPersonalSessionStatus), + "filter[expires_before]": v.optional( + v.union([v.pipe(v.string(), v.isoTimestamp()), v.null()]), + ), + "filter[expires_after]": v.optional( + v.union([v.pipe(v.string(), v.isoTimestamp()), v.null()]), + ), + "filter[expires]": v.optional(v.union([v.boolean(), v.null()])), +}); + +/** + * A personal session (session using personal access tokens) + */ +export const vPersonalSession = v.object({ + created_at: v.pipe(v.string(), v.isoTimestamp()), + revoked_at: v.optional( + v.union([v.pipe(v.string(), v.isoTimestamp()), v.null()]), + ), + owner_user_id: v.optional(v.union([v.string(), v.null()])), + owner_client_id: v.optional(v.union([v.string(), v.null()])), + actor_user_id: vUlid, + human_name: v.string(), + scope: v.string(), + last_active_at: v.optional( + v.union([v.pipe(v.string(), v.isoTimestamp()), v.null()]), + ), + last_active_ip: v.optional(v.union([v.string(), v.null()])), + expires_at: v.optional( + v.union([v.pipe(v.string(), v.isoTimestamp()), v.null()]), + ), + access_token: v.optional(v.union([v.string(), v.null()])), +}); + +/** + * A single resource, with its type, ID, attributes and related links + */ +export const vSingleResourceForPersonalSession = v.object({ + type: v.string(), + id: vUlid, + attributes: vPersonalSession, + links: vSelfLinks, + meta: v.optional(vSingleResourceMeta), +}); + +/** + * A top-level response with a page of resources + */ +export const vPaginatedResponseForPersonalSession = v.object({ + meta: v.optional(vPaginationMeta), + data: v.optional( + v.union([v.array(vSingleResourceForPersonalSession), v.null()]), + ), + links: vPaginationLinks, +}); + +/** + * JSON payload for the `POST /api/admin/v1/personal-sessions` endpoint + */ +export const vCreatePersonalSessionRequest = v.object({ + actor_user_id: vUlid, + human_name: v.string(), + scope: v.string(), + expires_in: v.optional( + v.union([ + v.pipe( + v.number(), + v.integer(), + v.minValue(0, "Invalid value: Expected uint32 to be >= 0"), + v.maxValue( + 4294967295, + "Invalid value: Expected uint32 to be <= 2^32-1", + ), + v.minValue(0), + ), + v.null(), + ]), + ), +}); + +/** + * A top-level response with a single resource + */ +export const vSingleResponseForPersonalSession = v.object({ + data: vSingleResourceForPersonalSession, + links: vSelfLinks, +}); + +/** + * JSON payload for the `POST /api/admin/v1/personal-sessions/{id}/regenerate` endpoint + */ +export const vRegeneratePersonalSessionRequest = v.object({ + expires_in: v.optional( + v.union([ + v.pipe( + v.number(), + v.integer(), + v.minValue(0, "Invalid value: Expected uint32 to be >= 0"), + v.maxValue( + 4294967295, + "Invalid value: Expected uint32 to be <= 2^32-1", + ), + v.minValue(0), + ), + v.null(), + ]), + ), +}); + /** * JSON payload for the `POST /api/admin/v1/policy-data` */ @@ -817,6 +932,93 @@ export const vFinishOAuth2SessionData = v.object({ */ export const vFinishOAuth2SessionResponse = vSingleResponseForOAuth2Session; +export const vListPersonalSessionsData = v.object({ + body: v.optional(v.never()), + path: v.optional(v.never()), + query: v.optional( + v.object({ + "page[before]": v.optional(vUlid), + "page[after]": v.optional(vUlid), + "page[first]": v.optional( + v.union([v.pipe(v.number(), v.integer(), v.minValue(1)), v.null()]), + ), + "page[last]": v.optional( + v.union([v.pipe(v.number(), v.integer(), v.minValue(1)), v.null()]), + ), + count: v.optional(vIncludeCount), + "filter[owner_user]": v.optional(vUlid), + "filter[owner_client]": v.optional(vUlid), + "filter[actor_user]": v.optional(vUlid), + "filter[scope]": v.optional(v.array(v.string()), []), + "filter[status]": v.optional(vPersonalSessionStatus), + "filter[expires_before]": v.optional( + v.union([v.pipe(v.string(), v.isoTimestamp()), v.null()]), + ), + "filter[expires_after]": v.optional( + v.union([v.pipe(v.string(), v.isoTimestamp()), v.null()]), + ), + "filter[expires]": v.optional(v.union([v.boolean(), v.null()])), + }), + ), +}); + +/** + * Paginated response of personal sessions + */ +export const vListPersonalSessionsResponse = + vPaginatedResponseForPersonalSession; + +export const vCreatePersonalSessionData = v.object({ + body: vCreatePersonalSessionRequest, + path: v.optional(v.never()), + query: v.optional(v.never()), +}); + +/** + * Personal session and personal access token were created + */ +export const vCreatePersonalSessionResponse = vSingleResponseForPersonalSession; + +export const vGetPersonalSessionData = v.object({ + body: v.optional(v.never()), + path: v.object({ + id: vUlid, + }), + query: v.optional(v.never()), +}); + +/** + * Personal session details + */ +export const vGetPersonalSessionResponse = vSingleResponseForPersonalSession; + +export const vRevokePersonalSessionData = v.object({ + body: v.optional(v.never()), + path: v.object({ + id: vUlid, + }), + query: v.optional(v.never()), +}); + +/** + * Personal session was revoked + */ +export const vRevokePersonalSessionResponse = vSingleResponseForPersonalSession; + +export const vRegeneratePersonalSessionData = v.object({ + body: vRegeneratePersonalSessionRequest, + path: v.object({ + id: vUlid, + }), + query: v.optional(v.never()), +}); + +/** + * Personal session was regenerated and a personal access token was created + */ +export const vRegeneratePersonalSessionResponse = + vSingleResponseForPersonalSession; + export const vSetPolicyDataData = v.object({ body: vSetPolicyDataRequest, path: v.optional(v.never()), diff --git a/src/api/mas/index.ts b/src/api/mas/index.ts index 094282a..b198e7f 100644 --- a/src/api/mas/index.ts +++ b/src/api/mas/index.ts @@ -157,6 +157,24 @@ export interface EditTokenParameters { expires_at?: string | null; // ISO date string } +export interface PersonalSessionListParameters extends PageParameters { + status?: api.PersonalSessionStatus; + actor_user?: api.Ulid; + expires?: boolean; + scope?: string[]; +} + +export interface CreatePersonalSessionParameters { + actor_user_id: api.Ulid; + human_name: string; + scope: string; + expires_in?: number | null; +} + +interface RegeneratePersonalSessionParameters { + expires_in?: number | null; +} + // FIXME: pagination direction is temporary until MAS gets proper ordering in the API type PaginationDirection = "forward" | "backward"; @@ -194,6 +212,30 @@ export const siteConfigQuery = (serverName: string) => }, }); +export const versionQuery = (serverName: string) => + queryOptions({ + queryKey: ["mas", "version", serverName], + queryFn: async ({ client, signal }): Promise => { + try { + const result = await api.version({ + ...(await masBaseOptions(client, serverName, signal)), + }); + ensureNoError(result); + return result.data; + } catch (error) { + console.warn( + "Version query failed, this is likely because of talking to an older version of MAS, ignoring", + error, + ); + + // Fallback to an unknown version, valid semver + return { + version: "v0.0.0-unknown", + }; + } + }, + }); + export const usersInfiniteQuery = ( serverName: string, parameters: UserListFilters = {}, @@ -648,6 +690,151 @@ export const unrevokeRegistrationToken = async ( return result.data; }; +export const personalSessionsInfiniteQuery = ( + serverName: string, + parameters: PersonalSessionListParameters = {}, +) => + infiniteQueryOptions({ + queryKey: ["mas", "personal-sessions", serverName, parameters], + queryFn: async ({ + client, + signal, + pageParam, + }): Promise> => { + const query: api.ListPersonalSessionsData["query"] = { + "page[first]": PAGE_SIZE, + count: "false", + }; + + if (pageParam) query["page[after]"] = pageParam; + + if (parameters.status !== undefined) + query["filter[status]"] = parameters.status; + if (parameters.actor_user !== undefined) + query["filter[actor_user]"] = parameters.actor_user; + if (parameters.expires !== undefined) + query["filter[expires]"] = parameters.expires; + if (parameters.scope !== undefined) + query["filter[scope]"] = parameters.scope; + + const result = await api.listPersonalSessions({ + ...(await masBaseOptions(client, serverName, signal)), + query, + }); + ensureNoError(result); + ensureHasData(result.data); + return result.data; + }, + initialPageParam: null as api.Ulid | null, + getNextPageParam: (lastPage): api.Ulid | null => + (lastPage.links.next && cursorForSingleResource(lastPage.data?.at(-1))) ?? + null, + }); + +export const personalSessionsCountQuery = ( + serverName: string, + parameters: PersonalSessionListParameters = {}, +) => + queryOptions({ + queryKey: ["mas", "personal-sessions", serverName, parameters, "count"], + queryFn: async ({ client, signal }): Promise => { + const query: api.ListPersonalSessionsData["query"] = { + count: "only", + }; + + if (parameters.status !== undefined) + query["filter[status]"] = parameters.status; + if (parameters.actor_user !== undefined) + query["filter[actor_user]"] = parameters.actor_user; + if (parameters.expires !== undefined) + query["filter[expires]"] = parameters.expires; + if (parameters.scope !== undefined) + query["filter[scope]"] = parameters.scope; + + const result = await api.listPersonalSessions({ + ...(await masBaseOptions(client, serverName, signal)), + query, + }); + ensureNoError(result); + ensureHasCount(result.data); + return result.data.meta.count; + }, + }); + +export const personalSessionQuery = (serverName: string, sessionId: string) => + queryOptions({ + queryKey: ["mas", "personal-session", serverName, sessionId], + queryFn: async ({ + client, + signal, + }): Promise => { + const result = await api.getPersonalSession({ + ...(await masBaseOptions(client, serverName, signal)), + path: { id: sessionId }, + }); + ensureNoError(result, true); + return result.data; + }, + }); + +export const createPersonalSession = async ( + queryClient: QueryClient, + serverName: string, + parameters: CreatePersonalSessionParameters, + signal?: AbortSignal, +): Promise => { + const body: api.CreatePersonalSessionData["body"] = { + actor_user_id: parameters.actor_user_id, + human_name: parameters.human_name, + scope: parameters.scope, + }; + + if (parameters.expires_in !== undefined) + body.expires_in = parameters.expires_in; + + const result = await api.createPersonalSession({ + ...(await masBaseOptions(queryClient, serverName, signal)), + body, + }); + ensureNoError(result); + return result.data; +}; + +export const revokePersonalSession = async ( + queryClient: QueryClient, + serverName: string, + sessionId: api.Ulid, + signal?: AbortSignal, +): Promise => { + const result = await api.revokePersonalSession({ + ...(await masBaseOptions(queryClient, serverName, signal)), + path: { id: sessionId }, + }); + ensureNoError(result); + return result.data; +}; + +export const regeneratePersonalSession = async ( + queryClient: QueryClient, + serverName: string, + sessionId: api.Ulid, + parameters: RegeneratePersonalSessionParameters = {}, + signal?: AbortSignal, +): Promise => { + const body: api.RegeneratePersonalSessionData["body"] = {}; + + if (parameters.expires_in !== undefined) + body.expires_in = parameters.expires_in; + + const result = await api.regeneratePersonalSession({ + ...(await masBaseOptions(queryClient, serverName, signal)), + path: { id: sessionId }, + body, + }); + ensureNoError(result); + return result.data; +}; + export const editRegistrationToken = async ( queryClient: QueryClient, serverName: string, diff --git a/src/components/page.module.css b/src/components/page.module.css index dbcb0ab..6472712 100644 --- a/src/components/page.module.css +++ b/src/components/page.module.css @@ -29,6 +29,15 @@ color: var(--cpd-color-text-primary); } +.description { + font: var(--cpd-font-body-md-regular); + letter-spacing: var(--cpd-font-letter-spacing-body-md); + color: var(--cpd-color-text-secondary); + max-width: 60ch; + text-align: justify; + grid-column: 1 / span 2; +} + .search { grid-area: search; display: flex; diff --git a/src/components/page.tsx b/src/components/page.tsx index 4aa9d46..b0cab32 100644 --- a/src/components/page.tsx +++ b/src/components/page.tsx @@ -22,6 +22,17 @@ export const Title = ({ className, children, ...props }: TitleProps) => ( ); +type DescriptionProps = React.ComponentProps<"p">; +export const Description = ({ + className, + children, + ...props +}: DescriptionProps) => ( +

+ {children} +

+); + type ControlsProps = React.ComponentProps<"div">; export const Controls = ({ className, children, ...props }: ControlsProps) => (
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index bfad5b0..2b3d495 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -16,10 +16,12 @@ import { Route as ConsoleIndexRouteImport } from './routes/_console.index' import { Route as ConsoleUsersRouteImport } from './routes/_console.users' import { Route as ConsoleRoomsRouteImport } from './routes/_console.rooms' import { Route as ConsoleRegistrationTokensRouteImport } from './routes/_console.registration-tokens' +import { Route as ConsolePersonalTokensRouteImport } from './routes/_console.personal-tokens' import { Route as AuthLoginIndexRouteImport } from './routes/_auth.login.index' import { Route as ConsoleUsersUserIdRouteImport } from './routes/_console.users.$userId' import { Route as ConsoleRoomsRoomIdRouteImport } from './routes/_console.rooms.$roomId' import { Route as ConsoleRegistrationTokensTokenIdRouteImport } from './routes/_console.registration-tokens.$tokenId' +import { Route as ConsolePersonalTokensTokenIdRouteImport } from './routes/_console.personal-tokens.$tokenId' const CallbackRoute = CallbackRouteImport.update({ id: '/callback', @@ -55,6 +57,11 @@ const ConsoleRegistrationTokensRoute = path: '/registration-tokens', getParentRoute: () => ConsoleRoute, } as any) +const ConsolePersonalTokensRoute = ConsolePersonalTokensRouteImport.update({ + id: '/personal-tokens', + path: '/personal-tokens', + getParentRoute: () => ConsoleRoute, +} as any) const AuthLoginIndexRoute = AuthLoginIndexRouteImport.update({ id: '/login/', path: '/login/', @@ -76,13 +83,21 @@ const ConsoleRegistrationTokensTokenIdRoute = path: '/$tokenId', getParentRoute: () => ConsoleRegistrationTokensRoute, } as any) +const ConsolePersonalTokensTokenIdRoute = + ConsolePersonalTokensTokenIdRouteImport.update({ + id: '/$tokenId', + path: '/$tokenId', + getParentRoute: () => ConsolePersonalTokensRoute, + } as any) export interface FileRoutesByFullPath { '/callback': typeof CallbackRoute + '/personal-tokens': typeof ConsolePersonalTokensRouteWithChildren '/registration-tokens': typeof ConsoleRegistrationTokensRouteWithChildren '/rooms': typeof ConsoleRoomsRouteWithChildren '/users': typeof ConsoleUsersRouteWithChildren '/': typeof ConsoleIndexRoute + '/personal-tokens/$tokenId': typeof ConsolePersonalTokensTokenIdRoute '/registration-tokens/$tokenId': typeof ConsoleRegistrationTokensTokenIdRoute '/rooms/$roomId': typeof ConsoleRoomsRoomIdRoute '/users/$userId': typeof ConsoleUsersUserIdRoute @@ -90,10 +105,12 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/callback': typeof CallbackRoute + '/personal-tokens': typeof ConsolePersonalTokensRouteWithChildren '/registration-tokens': typeof ConsoleRegistrationTokensRouteWithChildren '/rooms': typeof ConsoleRoomsRouteWithChildren '/users': typeof ConsoleUsersRouteWithChildren '/': typeof ConsoleIndexRoute + '/personal-tokens/$tokenId': typeof ConsolePersonalTokensTokenIdRoute '/registration-tokens/$tokenId': typeof ConsoleRegistrationTokensTokenIdRoute '/rooms/$roomId': typeof ConsoleRoomsRoomIdRoute '/users/$userId': typeof ConsoleUsersUserIdRoute @@ -104,10 +121,12 @@ export interface FileRoutesById { '/_auth': typeof AuthRouteWithChildren '/_console': typeof ConsoleRouteWithChildren '/callback': typeof CallbackRoute + '/_console/personal-tokens': typeof ConsolePersonalTokensRouteWithChildren '/_console/registration-tokens': typeof ConsoleRegistrationTokensRouteWithChildren '/_console/rooms': typeof ConsoleRoomsRouteWithChildren '/_console/users': typeof ConsoleUsersRouteWithChildren '/_console/': typeof ConsoleIndexRoute + '/_console/personal-tokens/$tokenId': typeof ConsolePersonalTokensTokenIdRoute '/_console/registration-tokens/$tokenId': typeof ConsoleRegistrationTokensTokenIdRoute '/_console/rooms/$roomId': typeof ConsoleRoomsRoomIdRoute '/_console/users/$userId': typeof ConsoleUsersUserIdRoute @@ -117,10 +136,12 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/callback' + | '/personal-tokens' | '/registration-tokens' | '/rooms' | '/users' | '/' + | '/personal-tokens/$tokenId' | '/registration-tokens/$tokenId' | '/rooms/$roomId' | '/users/$userId' @@ -128,10 +149,12 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo to: | '/callback' + | '/personal-tokens' | '/registration-tokens' | '/rooms' | '/users' | '/' + | '/personal-tokens/$tokenId' | '/registration-tokens/$tokenId' | '/rooms/$roomId' | '/users/$userId' @@ -141,10 +164,12 @@ export interface FileRouteTypes { | '/_auth' | '/_console' | '/callback' + | '/_console/personal-tokens' | '/_console/registration-tokens' | '/_console/rooms' | '/_console/users' | '/_console/' + | '/_console/personal-tokens/$tokenId' | '/_console/registration-tokens/$tokenId' | '/_console/rooms/$roomId' | '/_console/users/$userId' @@ -208,6 +233,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ConsoleRegistrationTokensRouteImport parentRoute: typeof ConsoleRoute } + '/_console/personal-tokens': { + id: '/_console/personal-tokens' + path: '/personal-tokens' + fullPath: '/personal-tokens' + preLoaderRoute: typeof ConsolePersonalTokensRouteImport + parentRoute: typeof ConsoleRoute + } '/_auth/login/': { id: '/_auth/login/' path: '/login' @@ -236,6 +268,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ConsoleRegistrationTokensTokenIdRouteImport parentRoute: typeof ConsoleRegistrationTokensRoute } + '/_console/personal-tokens/$tokenId': { + id: '/_console/personal-tokens/$tokenId' + path: '/$tokenId' + fullPath: '/personal-tokens/$tokenId' + preLoaderRoute: typeof ConsolePersonalTokensTokenIdRouteImport + parentRoute: typeof ConsolePersonalTokensRoute + } } } @@ -249,6 +288,19 @@ const AuthRouteChildren: AuthRouteChildren = { const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) +interface ConsolePersonalTokensRouteChildren { + ConsolePersonalTokensTokenIdRoute: typeof ConsolePersonalTokensTokenIdRoute +} + +const ConsolePersonalTokensRouteChildren: ConsolePersonalTokensRouteChildren = { + ConsolePersonalTokensTokenIdRoute: ConsolePersonalTokensTokenIdRoute, +} + +const ConsolePersonalTokensRouteWithChildren = + ConsolePersonalTokensRoute._addFileChildren( + ConsolePersonalTokensRouteChildren, + ) + interface ConsoleRegistrationTokensRouteChildren { ConsoleRegistrationTokensTokenIdRoute: typeof ConsoleRegistrationTokensTokenIdRoute } @@ -289,6 +341,7 @@ const ConsoleUsersRouteWithChildren = ConsoleUsersRoute._addFileChildren( ) interface ConsoleRouteChildren { + ConsolePersonalTokensRoute: typeof ConsolePersonalTokensRouteWithChildren ConsoleRegistrationTokensRoute: typeof ConsoleRegistrationTokensRouteWithChildren ConsoleRoomsRoute: typeof ConsoleRoomsRouteWithChildren ConsoleUsersRoute: typeof ConsoleUsersRouteWithChildren @@ -296,6 +349,7 @@ interface ConsoleRouteChildren { } const ConsoleRouteChildren: ConsoleRouteChildren = { + ConsolePersonalTokensRoute: ConsolePersonalTokensRouteWithChildren, ConsoleRegistrationTokensRoute: ConsoleRegistrationTokensRouteWithChildren, ConsoleRoomsRoute: ConsoleRoomsRouteWithChildren, ConsoleUsersRoute: ConsoleUsersRouteWithChildren, diff --git a/src/routes/_console.personal-tokens.$tokenId.tsx b/src/routes/_console.personal-tokens.$tokenId.tsx new file mode 100644 index 0000000..725c9cf --- /dev/null +++ b/src/routes/_console.personal-tokens.$tokenId.tsx @@ -0,0 +1,696 @@ +// SPDX-FileCopyrightText: Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + +import { + useMutation, + useQuery, + useQueryClient, + useSuspenseQuery, +} from "@tanstack/react-query"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { + ArrowLeftIcon, + CloseIcon, + RestartIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; +import { + Badge, + Button, + Form, + H3, + InlineSpinner, + Text, + Tooltip, +} from "@vector-im/compound-web"; +import { useCallback, useState } from "react"; +import { toast } from "react-hot-toast"; +import { FormattedMessage, useIntl } from "react-intl"; + +import { + personalSessionQuery, + regeneratePersonalSession, + revokePersonalSession, + userQuery, +} from "@/api/mas"; +import type { SingleResourceForPersonalSession } from "@/api/mas/api"; +import { wellKnownQuery, whoamiQuery } from "@/api/matrix"; +import { CopyToClipboard } from "@/components/copy"; +import * as Data from "@/components/data"; +import * as Dialog from "@/components/dialog"; +import { ButtonLink, TextLink } from "@/components/link"; +import * as Navigation from "@/components/navigation"; +import * as messages from "@/messages"; +import { computeHumanReadableDateTimeStringFromUtc } from "@/utils/datetime"; + +export const Route = createFileRoute("/_console/personal-tokens/$tokenId")({ + loader: async ({ context: { queryClient, credentials }, params }) => { + const wellKnown = await queryClient.ensureQueryData( + wellKnownQuery(credentials.serverName), + ); + const synapseRoot = wellKnown["m.homeserver"].base_url; + await queryClient.ensureQueryData(whoamiQuery(synapseRoot)); + + const { data: session } = await queryClient.ensureQueryData( + personalSessionQuery(credentials.serverName, params.tokenId), + ); + await queryClient.ensureQueryData( + userQuery(credentials.serverName, session.attributes.actor_user_id), + ); + if (session.attributes.owner_user_id) { + await queryClient.ensureQueryData( + userQuery(credentials.serverName, session.attributes.owner_user_id), + ); + } + }, + component: TokenDetailComponent, + notFoundComponent: NotFoundComponent, +}); + +function NotFoundComponent() { + const navigate = useNavigate(); + + return ( + +
+ + + + + + + +
+
+ ); +} + +const CloseSidebar = () => { + const intl = useIntl(); + const search = Route.useSearch(); + return ( +
+ + + +
+ ); +}; + +const Scope = ({ scope }: { scope: string }) => { + switch (scope) { + case "urn:mas:admin": { + return ( + + ); + } + case "urn:matrix:client:api:*": { + return ( + + ); + } + case "urn:synapse:admin:*": { + return ( + + ); + } + default: { + return scope; + } + } +}; + +function TokenDetailComponent() { + const intl = useIntl(); + const { credentials } = Route.useRouteContext(); + const parameters = Route.useParams(); + const queryClient = useQueryClient(); + + const { data: wellKnown } = useSuspenseQuery( + wellKnownQuery(credentials.serverName), + ); + const synapseRoot = wellKnown["m.homeserver"].base_url; + const { data: whoami } = useSuspenseQuery(whoamiQuery(synapseRoot)); + + const { + data: { data: token }, + } = useSuspenseQuery( + personalSessionQuery(credentials.serverName, parameters.tokenId), + ); + + const { + data: { data: actor }, + } = useSuspenseQuery( + userQuery(credentials.serverName, token.attributes.actor_user_id), + ); + + const { data: ownerData } = useQuery({ + ...userQuery(credentials.serverName, token.attributes.owner_user_id || ""), + enabled: !!token.attributes.owner_user_id, + }); + + const actorMxid = `@${actor.attributes.username}:${credentials.serverName}`; + const ownerMxid = ownerData + ? `@${ownerData.data.attributes.username}:${credentials.serverName}` + : undefined; + + const amITheOwner = ownerMxid === whoami.user_id; + + const revokeTokenMutation = useMutation({ + mutationFn: async () => + revokePersonalSession( + queryClient, + credentials.serverName, + parameters.tokenId, + ), + onSuccess: (data) => { + // Update the token query data + queryClient.setQueryData( + ["mas", "personal-session", credentials.serverName, parameters.tokenId], + data, + ); + + // Invalidate tokens list query to reflect new data + queryClient.invalidateQueries({ + queryKey: ["mas", "personal-sessions", credentials.serverName], + }); + + toast.success( + intl.formatMessage({ + id: "pages.personal_tokens.revoke_success", + defaultMessage: "Personal token revoked successfully", + description: "Success message when a personal token is revoked", + }), + ); + }, + }); + + const scope = token.attributes.scope.split(" "); + + return ( + + + +
+

{token.attributes.human_name}

+ + + + + + + + + + + + + + + + + + {actorMxid} + + + + + {ownerData && ( + + + + + + + {ownerMxid} + + + + )} + + + + + + {scope.map((scope) => ( + + + + ))} + + + + + + + + {computeHumanReadableDateTimeStringFromUtc( + token.attributes.created_at, + )} + + + + + + + + + {token.attributes.expires_at + ? computeHumanReadableDateTimeStringFromUtc( + token.attributes.expires_at, + ) + : intl.formatMessage({ + id: "pages.personal_tokens.never_expires", + defaultMessage: "Never expires", + description: + "Text shown when a token has no expiration date", + })} + + + + {token.attributes.last_active_at && ( + + + + + + {computeHumanReadableDateTimeStringFromUtc( + token.attributes.last_active_at, + )} + + + )} + + {token.attributes.last_active_ip && ( + + + + + {token.attributes.last_active_ip} + + )} + + {token.attributes.revoked_at && ( + + + + + + {computeHumanReadableDateTimeStringFromUtc( + token.attributes.revoked_at, + )} + + + )} + + + {!token.attributes.revoked_at && ( + <> + {amITheOwner ? ( + + ) : ( + + + + )} + + + + )} +
+
+ ); +} + +interface PersonalTokenStatusBadgeProps { + token: SingleResourceForPersonalSession; +} + +function PersonalTokenStatusBadge({ + token, +}: PersonalTokenStatusBadgeProps): React.ReactElement { + if (token.attributes.revoked_at) { + return ( + + + + ); + } + + if (token.attributes.expires_at) { + const expiryDate = new Date(token.attributes.expires_at); + const now = new Date(); + if (expiryDate <= now) { + return ( + + + + ); + } + } + + return ( + + + + ); +} + +interface RegenerateTokenModalProps { + token: SingleResourceForPersonalSession; + serverName: string; +} + +function RegenerateTokenModal({ + token, + serverName, +}: RegenerateTokenModalProps) { + const queryClient = useQueryClient(); + const [isOpen, setIsOpen] = useState(false); + const intl = useIntl(); + + const regenerateTokenMutation = useMutation({ + mutationFn: async (expiresIn: null | number) => + regeneratePersonalSession(queryClient, serverName, token.id, { + expires_in: expiresIn, + }), + onError: () => { + toast.error( + intl.formatMessage({ + id: "pages.personal_tokens.regenerate_error", + defaultMessage: "Failed to regenerate personal token", + description: "Error message when regenerating a personal token fails", + }), + ); + }, + onSuccess: (data) => { + toast.success( + intl.formatMessage({ + id: "pages.personal_tokens.regenerate_success", + defaultMessage: "Personal token regenerated successfully", + description: "Success message when a personal token is regenerated", + }), + ); + + // Update the token query data + queryClient.setQueryData( + ["mas", "personal-session", serverName, token.id], + data, + ); + + // Invalidate tokens list query to reflect new data + queryClient.invalidateQueries({ + queryKey: ["mas", "personal-sessions", serverName], + }); + }, + }); + + const { + mutate, + isPending, + data: mutationData, + reset, + } = regenerateTokenMutation; + + const handleSubmit = useCallback( + (event: React.FormEvent) => { + event.preventDefault(); + if (isPending) { + return; + } + + const formData = new FormData(event.currentTarget); + const expiresInDays = formData.get("expires_in_days") as string; + + const expiresIn = + expiresInDays && expiresInDays !== "" + ? Number.parseInt(expiresInDays, 10) * 24 * 60 * 60 + : null; + + mutate(expiresIn); + }, + [mutate, isPending], + ); + + const handleClose = useCallback(() => { + if (isPending) { + return; + } + + setIsOpen(false); + reset(); + }, [isPending, reset]); + + return ( + + + + } + > + + {mutationData?.data.attributes.access_token ? ( + + ) : ( + + )} + + + + {mutationData?.data.attributes.access_token ? ( +
+ + + + +
+ + +
+
+ ) : ( + +
+ + + + + + + + + + + + + +
+ + + {isPending && } + + +
+ )} +
+ + + + +
+ ); +} diff --git a/src/routes/_console.personal-tokens.tsx b/src/routes/_console.personal-tokens.tsx new file mode 100644 index 0000000..0e1b0e6 --- /dev/null +++ b/src/routes/_console.personal-tokens.tsx @@ -0,0 +1,990 @@ +// SPDX-FileCopyrightText: Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + +import { + useMutation, + useQuery, + useQueryClient, + useSuspenseInfiniteQuery, + useSuspenseQuery, +} from "@tanstack/react-query"; +import { + Link, + Outlet, + createFileRoute, + useNavigate, +} from "@tanstack/react-router"; +import type { ColumnDef } from "@tanstack/react-table"; +import { getCoreRowModel, useReactTable } from "@tanstack/react-table"; +import { PlusIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { + Avatar, + Badge, + Button, + CheckboxMenuItem, + Form, + InlineSpinner, + Text, +} from "@vector-im/compound-web"; +import { Suspense, useCallback, useMemo, useRef, useState } from "react"; +import { toast } from "react-hot-toast"; +import { FormattedMessage, defineMessage, useIntl } from "react-intl"; +import * as v from "valibot"; + +import { + type CreatePersonalSessionParameters, + type PersonalSessionListParameters, + createPersonalSession, + personalSessionsCountQuery, + personalSessionsInfiniteQuery, + userQuery, +} from "@/api/mas"; +import type { SingleResourceForPersonalSession } from "@/api/mas/api/types.gen"; +import { + mediaThumbnailQuery, + profileQuery, + wellKnownQuery, +} from "@/api/matrix"; +import { CopyToClipboard } from "@/components/copy"; +import * as Dialog from "@/components/dialog"; +import { TextLink } from "@/components/link"; +import * as Navigation from "@/components/navigation"; +import * as Page from "@/components/page"; +import * as Placeholder from "@/components/placeholder"; +import * as Table from "@/components/table"; +import * as messages from "@/messages"; +import AppFooter from "@/ui/footer"; +import { UserPicker } from "@/ui/user-picker"; +import { useImageBlob } from "@/utils/blob"; +import { computeHumanReadableDateTimeStringFromUtc } from "@/utils/datetime"; +import { useFilters } from "@/utils/filters"; +import { randomString } from "@/utils/random"; +import { useCurrentChildRoutePath } from "@/utils/routes"; + +const SYNAPSE_ADMIN_SCOPE = "urn:synapse:admin:*"; +const MATRIX_API_SCOPE = "urn:matrix:client:api:*"; +const DEVICE_SCOPE = "urn:matrix:client:device:"; +const MAS_ADMIN_SCOPE = "urn:mas:admin"; + +const PersonalTokenSearchParameters = v.object({ + status: v.optional(v.picklist(["active", "revoked"])), + actor_user: v.optional(v.string()), + expires: v.optional(v.boolean()), + scope: v.optional(v.string()), +}); + +const titleMessage = defineMessage({ + id: "pages.personal_tokens.title", + defaultMessage: "Personal tokens", + description: "The title of the personal tokens list page", +}); + +const descriptionMessage = defineMessage({ + id: "pages.personal_tokens.description", + defaultMessage: + "Personal tokens are long-lived access tokens with specific access, including Synapse and MAS administration API access. They are useful for automating tasks and for creating integrations.", + description: "The description of the personal tokens list page", +}); + +export const Route = createFileRoute("/_console/personal-tokens")({ + staticData: { + breadcrumb: { + message: titleMessage, + }, + }, + + validateSearch: PersonalTokenSearchParameters, + + loaderDeps: ({ search }) => ({ + parameters: { + ...(search.status !== undefined && { status: search.status }), + ...(search.actor_user !== undefined && { actor_user: search.actor_user }), + ...(search.expires !== undefined && { expires: search.expires }), + ...(search.scope !== undefined && { scope: search.scope.split(" ") }), + } satisfies PersonalSessionListParameters, + }), + loader: async ({ + context: { queryClient, credentials }, + deps: { parameters }, + }) => { + // Kick off the token count query without awaiting it + queryClient.prefetchQuery( + personalSessionsCountQuery(credentials.serverName, parameters), + ); + + await Promise.all([ + queryClient.ensureQueryData(wellKnownQuery(credentials.serverName)), + queryClient.ensureInfiniteQueryData( + personalSessionsInfiniteQuery(credentials.serverName, parameters), + ), + ]); + }, + + pendingComponent: () => ( + <> + + + + + + + + + + + + + + + + + + + + + + + + ), + + component: RouteComponent, +}); + +interface PersonalTokenStatusBadgeProps { + token: SingleResourceForPersonalSession["attributes"]; +} + +function PersonalTokenStatusBadge({ + token, +}: PersonalTokenStatusBadgeProps): React.ReactElement { + if (token.revoked_at) { + return ( + + + + ); + } + + if (token.expires_at) { + const expiryDate = new Date(token.expires_at); + const now = new Date(); + if (expiryDate <= now) { + return ( + + + + ); + } + } + + return ( + + + + ); +} + +interface PersonalTokenAddButtonProps { + serverName: string; + synapseRoot: string; +} + +const PersonalTokenAddButton = ({ + serverName, + synapseRoot, +}: PersonalTokenAddButtonProps) => { + const [isOpen, setIsOpen] = useState(false); + + // State for conditional rendering and dependencies + const [matrixClientChecked, setMatrixClientChecked] = useState(false); + const [deviceChecked, setDeviceChecked] = useState(false); + const [synapseAdminChecked, setSynapseAdminChecked] = useState(false); + + const queryClient = useQueryClient(); + const intl = useIntl(); + const from = useCurrentChildRoutePath(Route.id); + const navigate = useNavigate({ from }); + const [missingActor, setMissingActor] = useState(false); + + // Checkbox handlers with dependency logic + const onMatrixClientChecked = useCallback( + (event: React.ChangeEvent) => { + const newValue = event.currentTarget.checked; + setMatrixClientChecked(newValue); + if (!newValue) { + setDeviceChecked(false); + setSynapseAdminChecked(false); + } + }, + [], + ); + + const onDeviceChecked = useCallback( + (event: React.ChangeEvent) => { + const newValue = event.currentTarget.checked; + setDeviceChecked(newValue); + if (newValue) { + setMatrixClientChecked(true); + } + return newValue; + }, + [], + ); + + const onSynapseAdminChecked = useCallback( + (event: React.ChangeEvent) => { + const newValue = event.currentTarget.checked; + setSynapseAdminChecked(newValue); + if (newValue) { + setMatrixClientChecked(true); + } + return newValue; + }, + [], + ); + + const { + mutate, + isPending, + data: mutationData, + reset, + } = useMutation({ + mutationFn: (parameters: CreatePersonalSessionParameters) => + createPersonalSession(queryClient, serverName, parameters), + onError: () => { + toast.error( + intl.formatMessage({ + id: "pages.personal_tokens.create_error", + defaultMessage: "Failed to create personal token", + description: "Error message when creating a personal token fails", + }), + ); + }, + onSuccess: async (result) => { + toast.success( + intl.formatMessage({ + id: "pages.personal_tokens.create_success", + defaultMessage: "Personal token created successfully", + description: "Success message when a personal token is created", + }), + ); + + // Invalidate the list to refresh the data + await queryClient.invalidateQueries({ + queryKey: ["mas", "personal-sessions", serverName], + }); + + await navigate({ + to: "/personal-tokens/$tokenId", + params: { tokenId: result.data.id }, + }); + }, + }); + + const onSubmit = useCallback( + (event: React.FormEvent) => { + event.preventDefault(); + if (isPending) { + return; + } + + const formData = new FormData(event.currentTarget); + const humanName = formData.get("human_name") as string; + const actorUserId = formData.get("actor_user_id") as string; + const expiresInDays = formData.get("expires_in_days") as string; + + // Somewhat of a hack to show the input as invalid if no user is selected + if (!actorUserId) { + setMissingActor(true); + return; + } + + // Build scope string from form data + const scopes = []; + if (formData.get("scope_mas_admin")) scopes.push(MAS_ADMIN_SCOPE); + if (formData.get("scope_matrix_client")) scopes.push(MATRIX_API_SCOPE); + if (formData.get("scope_synapse_admin")) scopes.push(SYNAPSE_ADMIN_SCOPE); + if (formData.get("scope_device")) { + const deviceId = (formData.get("device_id") as string) || ""; + const finalDeviceId = deviceId.trim() || randomString(10); + scopes.push(`${DEVICE_SCOPE}${finalDeviceId}`); + } + const scope = scopes.join(" "); + + const parameters: CreatePersonalSessionParameters = { + actor_user_id: actorUserId, + human_name: humanName, + scope, + }; + + if (expiresInDays && expiresInDays !== "") { + parameters.expires_in = + Number.parseInt(expiresInDays, 10) * 24 * 60 * 60; // Convert days to seconds + } + + mutate(parameters); + }, + [mutate, isPending], + ); + + const onOpenChange = useCallback( + (open: boolean) => { + // Prevent closing if mutation is pending + if (isPending) { + return; + } + + setIsOpen(open); + // Reset mutation data and form state + reset(); + setMatrixClientChecked(false); + setDeviceChecked(false); + setSynapseAdminChecked(false); + setMissingActor(false); + }, + [isPending, reset], + ); + + return ( + + + + } + > + + {mutationData?.data.attributes.access_token ? ( + + ) : ( + + )} + + + {mutationData?.data.attributes.access_token ? ( + <> + + + + + +
+ + +
+
+ + + + + + ) : ( + <> + + + + + + + + + + + + + + + + + + + + + + + } + > + {MAS_ADMIN_SCOPE} + + + + + + + } + > + {MATRIX_API_SCOPE} + + + + + + + } + > + {SYNAPSE_ADMIN_SCOPE} + + + + + + + } + > + {DEVICE_SCOPE} + + + + + + {deviceChecked && ( + + + + + + + + + + )} + + + + + + + + + + + + + {isPending && } + + + + + + + + + + )} +
+ ); +}; + +const useMxid = (serverName: string, userId: string): string => { + const { data } = useSuspenseQuery(userQuery(serverName, userId)); + return `@${data.data.attributes.username}:${serverName}`; +}; + +const useUserAvatar = ( + synapseRoot: string, + userId: string, +): string | undefined => { + const { data: profile } = useQuery(profileQuery(synapseRoot, userId)); + const { data: avatarBlob } = useQuery( + mediaThumbnailQuery(synapseRoot, profile?.avatar_url), + ); + return useImageBlob(avatarBlob); +}; + +const useUserDisplayName = ( + synapseRoot: string, + userId: string, +): string | undefined => { + const { data: profile } = useQuery(profileQuery(synapseRoot, userId)); + return profile?.displayname; +}; + +interface UserCellProps { + userId: string; + serverName: string; +} +const UserCell = ({ userId, serverName }: UserCellProps) => { + const { data: wellKnown } = useSuspenseQuery(wellKnownQuery(serverName)); + const synapseRoot = wellKnown["m.homeserver"].base_url; + const mxid = useMxid(serverName, userId); + const displayName = useUserDisplayName(synapseRoot, mxid); + const avatar = useUserAvatar(synapseRoot, mxid); + return ( +
+ +
+ {displayName ? ( + <> + + {displayName} + + + {mxid} + + + ) : ( + + {mxid} + + )} +
+
+ ); +}; + +const PersonalTokenCount = ({ serverName }: { serverName: string }) => { + const { data } = useSuspenseQuery(personalSessionsCountQuery(serverName)); + + return ( + + ); +}; + +const filtersDefinition = [ + { + key: "status", + value: "active", + message: defineMessage({ + id: "pages.personal_tokens.filter.active", + defaultMessage: "Active", + description: "Filter label for active personal tokens", + }), + }, + { + key: "status", + value: "revoked", + message: defineMessage({ + id: "pages.personal_tokens.filter.revoked", + defaultMessage: "Revoked", + description: "Filter label for revoked personal tokens", + }), + }, + { + key: "expires", + value: true, + message: defineMessage({ + id: "pages.personal_tokens.filter.expires", + defaultMessage: "With an expiry date", + description: "Filter label for tokens that expire", + }), + }, + { + key: "expires", + value: false, + message: defineMessage({ + id: "pages.personal_tokens.filter.no_expiry", + defaultMessage: "Never expires", + description: "Filter label for tokens that never expire", + }), + }, + { + key: "scope", + value: SYNAPSE_ADMIN_SCOPE, + message: defineMessage({ + id: "pages.personal_tokens.filter.scope_synapse_admin", + defaultMessage: "Access to the Synapse admin API", + description: "Filter label for synapse admin scope", + }), + }, + { + key: "scope", + value: MAS_ADMIN_SCOPE, + message: defineMessage({ + id: "pages.personal_tokens.filter.scope_mas_admin", + defaultMessage: "Access to the MAS admin API", + description: "Filter label for MAS admin scope", + }), + }, + { + key: "scope", + value: MATRIX_API_SCOPE, + message: defineMessage({ + id: "pages.personal_tokens.filter.scope_matrix_client", + defaultMessage: "Access to the Matrix Client-Server API", + description: "Filter label for Matrix Client API scope", + }), + }, +] as const; + +function RouteComponent() { + const { credentials } = Route.useRouteContext(); + const search = Route.useSearch(); + const { parameters } = Route.useLoaderDeps(); + const from = useCurrentChildRoutePath(Route.id); + const navigate = useNavigate({ from }); + const intl = useIntl(); + + const { data: wellKnown } = useSuspenseQuery( + wellKnownQuery(credentials.serverName), + ); + const synapseRoot = wellKnown["m.homeserver"].base_url; + + const { data, hasNextPage, fetchNextPage, isFetching } = + useSuspenseInfiniteQuery( + personalSessionsInfiniteQuery(credentials.serverName, parameters), + ); + + // Flatten the array of arrays from the useInfiniteQuery hook + const flatData = useMemo( + () => data?.pages?.flatMap((page) => page.data) ?? [], + [data], + ); + + const filters = useFilters(search, filtersDefinition); + + // Column definitions + const columns = useMemo[]>( + () => [ + { + id: "name", + header: intl.formatMessage({ + id: "pages.personal_tokens.name_column", + defaultMessage: "Name", + description: "Column header for token name column", + }), + cell: ({ row }) => { + const token = row.original; + return ( + + + {token.attributes.human_name} + + + ); + }, + }, + { + id: "actingUser", + header: intl.formatMessage({ + id: "pages.personal_tokens.acting_user_column", + defaultMessage: "Acting User", + description: "Column header for acting user column", + }), + cell: ({ row }) => { + const token = row.original; + return ( + }> + + + ); + }, + }, + { + id: "status", + header: intl.formatMessage({ + id: "pages.personal_tokens.status.column", + defaultMessage: "Status", + description: "Column header for status column", + }), + cell: ({ row }) => { + const token = row.original; + return ; + }, + }, + { + id: "lastActive", + header: intl.formatMessage({ + id: "pages.personal_tokens.last_active_column", + defaultMessage: "Last Active", + description: "Column header for last active column", + }), + cell: ({ row }) => { + const token = row.original; + return ( + + {token.attributes.last_active_at + ? computeHumanReadableDateTimeStringFromUtc( + token.attributes.last_active_at, + ) + : intl.formatMessage({ + id: "pages.personal_tokens.never_used", + defaultMessage: "Never used", + description: "Text shown when a token has never been used", + })} + + ); + }, + }, + { + id: "expiresAt", + header: intl.formatMessage({ + id: "pages.personal_tokens.expires_at_column", + defaultMessage: "Expires at", + description: "Column header for expires at column", + }), + cell: ({ row }) => { + const token = row.original; + return ( + + {token.attributes.expires_at + ? computeHumanReadableDateTimeStringFromUtc( + token.attributes.expires_at, + ) + : intl.formatMessage({ + id: "pages.personal_tokens.never_expires", + defaultMessage: "Never expires", + description: + "Text shown when a token has no expiration date", + })} + + ); + }, + }, + ], + [search, intl, credentials.serverName], + ); + + // eslint-disable-next-line react-hooks/incompatible-library -- We pass things as a ref to avoid this problem + const table = useReactTable({ + data: flatData, + columns, + getCoreRowModel: getCoreRowModel(), + manualSorting: true, + }); + + // This prevents the compiler from optimizing the table + // See https://github.com/TanStack/table/issues/5567 + const tableRef = useRef(table); + + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + {filters.all.map((filter) => ( + { + event.preventDefault(); + navigate({ + replace: true, + search: filter.toggledState, + }); + }} + label={intl.formatMessage(filter.message)} + checked={filter.enabled} + /> + ))} + + + {filters.active.length > 0 && ( + + {filters.active.map((filter) => ( + + + + + ))} + + + + + + )} + + + + + {/* Loading indicator */} + {isFetching && ( +
+ + + +
+ )} +
+
+ +
+ + ); +} diff --git a/src/routes/_console.tsx b/src/routes/_console.tsx index 77931e5..ee97865 100644 --- a/src/routes/_console.tsx +++ b/src/routes/_console.tsx @@ -33,6 +33,7 @@ import { useAuthStore } from "@/stores/auth"; import AppNavigation from "@/ui/navigation"; import { useImageBlob } from "@/utils/blob"; import type { WithBreadcrumbEntry } from "@/utils/breadcrumbs"; +import { getFeaturesStatus, useFeaturesStatus } from "@/utils/features"; interface TokenViewProps { token: string; @@ -70,6 +71,10 @@ export const Route = createFileRoute("/_console")({ ); const synapseRoot = wellKnown["m.homeserver"].base_url; + const masFeaturesPromise = getFeaturesStatus( + queryClient, + credentials.serverName, + ); const essVersionPromise = queryClient.ensureQueryData( essVersionQuery(synapseRoot), ); @@ -77,7 +82,7 @@ export const Route = createFileRoute("/_console")({ await queryClient.ensureQueryData( profileQuery(synapseRoot, whoami.user_id), ); - await essVersionPromise; + await Promise.all([essVersionPromise, masFeaturesPromise]); return { breadcrumb: { @@ -107,6 +112,7 @@ function RouteComponent() { const avatarUrl = useImageBlob(avatar); + const features = useFeaturesStatus(credentials.serverName); const variant = useEssVariant(synapseRoot); // An easter egg to trigger toasts and error boundaries @@ -172,7 +178,7 @@ function RouteComponent() { - + diff --git a/src/stores/auth.ts b/src/stores/auth.ts index e50d049..301f1a0 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -12,6 +12,7 @@ import { wellKnownQuery } from "@/api/matrix"; import { NotLoggedInError } from "@/errors"; import { reset } from "@/query"; import { router } from "@/router"; +import { randomString } from "@/utils/random"; import { addTimeout } from "@/utils/signal"; const REFRESH_LOCK = "element-admin-refresh-lock"; @@ -21,19 +22,6 @@ function normalizeServerName(serverName: string): string { return serverName.toLocaleLowerCase().trim(); } -function randomString(length: number): string { - const possible = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - const randomValues = new Uint8Array(length); - globalThis.crypto.getRandomValues(randomValues); - - let codeVerifier = ""; - for (const value of randomValues) { - codeVerifier += possible[value % possible.length]; - } - return codeVerifier; -} - async function sha256(plain: string): Promise { const encoder = new TextEncoder(); const data = encoder.encode(plain); diff --git a/src/ui/navigation.tsx b/src/ui/navigation.tsx index c827d36..cce3736 100644 --- a/src/ui/navigation.tsx +++ b/src/ui/navigation.tsx @@ -5,6 +5,7 @@ import { DocumentIcon, HomeIcon, + InlineCodeIcon, KeyIcon, LeaveIcon, UserProfileIcon, @@ -12,8 +13,9 @@ import { import { FormattedMessage } from "react-intl"; import * as Navigation from "@/components/navigation"; +import type { MasFeaturesStatus } from "@/utils/features"; -const AppNavigation = () => ( +const AppNavigation = ({ features }: { features: MasFeaturesStatus }) => ( ( description="Label for the dashboard navigation item in the main navigation sidebar" /> - + ( /> - + {features.personalTokens && ( + + + + )} + { + const { data: profileData } = useQuery(profileQuery(synapseRoot, mxid)); + const { data: avatarBlob } = useQuery( + mediaThumbnailQuery(synapseRoot, profileData?.avatar_url ?? undefined), + ); + const avatar = useImageBlob(avatarBlob); + const displayName = profileData?.displayname; + return ( + <> + +
+ {displayName ? ( + <> +
{displayName}
+
{mxid}
+ + ) : ( +
{mxid}
+ )} +
+ + ); +}; + +// This is a bit of a hack to remove the leading `@` as well as any trailing +// domain name from the search term, so that looking by full MXID works +const normalizeSearch = (term: string): string => + term.trim().replace(/^@/, "").replace(/:.*$/, "").toLocaleLowerCase(); + +export const UserPicker = ({ + serverName, + synapseRoot, +}: { + serverName: string; + synapseRoot: string; +}) => { + // Tracks the selected user + const [selectedUser, setSelectedUser] = + useState(null); + // Tracks the active/hovered index in the list + const [activeIndex, setActiveIndex] = useState(null); + + // This state is in-sync with the input, and the debounced one is the one kicking off the query + const [userSearch, setUserSearch] = useState(""); + const [debouncedUserSearch, { state }] = useDebouncedValue( + normalizeSearch(userSearch), + { wait: 200 }, + ({ isPending }) => ({ isPending }), + ); + + const listRef = useRef<(HTMLElement | null)[]>([]); + const [showList, setShowList] = useState(false); + const { refs, floatingStyles, context } = useFloating({ + open: showList, + onOpenChange: setShowList, + // Makes sure to update the sizing/positioning when the window is resizing, + // which is especially important on mobile + whileElementsMounted: autoUpdate, + middleware: [ + size({ + // Gives a bit of padding with the bottom of the screen + padding: 16, + apply({ rects, elements, availableHeight }) { + // Inject a few CSS variables to get the sizing right + elements.floating.style.setProperty( + "--reference-width", + `${rects.reference.width}px`, + ); + elements.floating.style.setProperty( + "--reference-height", + `${rects.reference.height}px`, + ); + elements.floating.style.setProperty( + "--available-height", + `${availableHeight}px`, + ); + }, + }), + ], + }); + + // This gives the right aria roles to the input and the list items + const role = useRole(context, { role: "combobox" }); + // This gives the keyboard list navigation behaviour + const listNav = useListNavigation(context, { + listRef, + activeIndex: activeIndex, + onNavigate: setActiveIndex, + // This keeps the focus on the text input rather than on the items + virtual: true, + }); + // This opens/closes the list when focusing the input + const focus = useFocus(context); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions( + [role, listNav, focus], + ); + + const { data, isPlaceholderData } = useInfiniteQuery({ + ...usersInfiniteQuery(serverName, { + search: debouncedUserSearch, + }), + placeholderData: (previous) => previous, + }); + + const isLoading = state.isPending || isPlaceholderData; + + const users = useMemo( + () => + data?.pages + ?.flatMap((page) => page.data) + .filter( + // Apply a local filter on the data we have for instant feedback + (user) => + user.attributes.deactivated_at === null && + user.attributes.username + .toLocaleLowerCase() + .includes(normalizeSearch(userSearch)), + ) ?? [], + [data, userSearch], + ); + + const onSearchInput = useCallback( + (event: React.ChangeEvent) => { + event.preventDefault(); + const newValue = event.currentTarget.value; + setUserSearch(newValue); + // Reset active index when searching + setActiveIndex(null); + }, + [], + ); + + // Select the current active item when pressing enter + const onSearchKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const activeUser = + activeIndex !== null && !isLoading && users[activeIndex]; + if (event.key === "Enter" && activeUser) { + event.preventDefault(); + setSelectedUser(activeUser); + } + }, + [activeIndex, isLoading, users], + ); + + const onClearSelection = useCallback(() => { + setShowList(false); + setUserSearch(""); + setSelectedUser(null); + }, []); + + const itemIdPrefix = useId(); + + if (selectedUser) { + return ( +
+ +
+ +
+
+ ); + } + + return ( + <> + { + refs.setReference(node); + }} + value={userSearch} + required + aria-autocomplete="list" + className={styles["reference"]} + {...getReferenceProps({ + onInput: onSearchInput, + onKeyDown: onSearchKeyDown, + })} + /> + {showList && ( + +
{ + refs.setFloating(node); + }} + style={floatingStyles} + className={styles["floating"]} + {...getFloatingProps()} + > + {users.length === 0 ? ( + isLoading ? ( +
+ +
+ ) : ( +
+
+ +
+
+ +
+
+ ) + ) : ( +
    + {users.map((user, index) => ( +
  • { + listRef.current[index] = node; + }} + {...getItemProps({ + id: `${itemIdPrefix}-${user.id}`, + onClick(event: React.MouseEvent) { + event.preventDefault(); + setSelectedUser(user); + }, + active: index === activeIndex, + })} + className={styles["item"]} + data-active={index === activeIndex} + tabIndex={index === activeIndex ? 0 : -1} + > + +
  • + ))} +
+ )} +
+
+ )} + + ); +}; diff --git a/src/utils/features.ts b/src/utils/features.ts new file mode 100644 index 0000000..145e76c --- /dev/null +++ b/src/utils/features.ts @@ -0,0 +1,66 @@ +// SPDX-FileCopyrightText: Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + +import { type QueryClient, useSuspenseQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { parse, gte } from "semver"; + +import { versionQuery } from "@/api/mas"; + +type SemverString = + | `v${number}.${number}.${number}` + | `v${number}.${number}.${number}-${string}`; + +const masFeaturesMinVersions = { + personalTokens: "v1.5.0-rc.0", +} as const satisfies Record; + +type MasFeature = keyof typeof masFeaturesMinVersions; + +export type MasFeaturesStatus = Record; + +const computeFeaturesStatus = (version: string): MasFeaturesStatus => { + const semver = parse(version, {}, true); + + return Object.fromEntries( + Object.entries(masFeaturesMinVersions).map(([feature, minVersion]) => [ + feature, + gte(semver, minVersion), + ]), + ) as MasFeaturesStatus; +}; + +/** + * A hook to get the availability of all the features on the given server + * + * @param serverName The server name to which the query is sent + * @returns A record indicating which features are available + */ +export const useFeaturesStatus = (serverName: string): MasFeaturesStatus => { + const { + data: { version }, + } = useSuspenseQuery(versionQuery(serverName)); + const featuresStatus = useMemo( + () => computeFeaturesStatus(version), + [version], + ); + return featuresStatus; +}; + +/** + * Get the availability of all the features on the given server + * + * @param queryClient The Tanstack Query client to use + * @param serverName The server name to which the query is sent + * @returns A record indicating which features are available + */ +export const getFeaturesStatus = async ( + queryClient: QueryClient, + serverName: string, +): Promise => { + const { version } = await queryClient.ensureQueryData( + versionQuery(serverName), + ); + return computeFeaturesStatus(version); +}; diff --git a/src/utils/random.ts b/src/utils/random.ts new file mode 100644 index 0000000..ecee57c --- /dev/null +++ b/src/utils/random.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial + +/** + * Generates a random string of specified length using alphanumeric characters. + * @param length The length of the random string to generate + * @returns A random string containing uppercase, lowercase, and numeric characters + */ +export function randomString(length: number): string { + const possible = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const randomValues = new Uint8Array(length); + globalThis.crypto.getRandomValues(randomValues); + + let result = ""; + for (const value of randomValues) { + result += possible[value % possible.length]; + } + return result; +} diff --git a/translations/extracted/en.json b/translations/extracted/en.json index 91c161e..244361b 100644 --- a/translations/extracted/en.json +++ b/translations/extracted/en.json @@ -87,6 +87,10 @@ "description": "Label for the documentation navigation link (to https://docs.element.io/) in the main navigation sidebar", "message": "Documentation" }, + "navigation.personal_tokens": { + "description": "Label for the personal tokens navigation item in the main navigation sidebar", + "message": "Personal tokens" + }, "navigation.registration_tokens": { "description": "Label for the registration tokens navigation item in the main navigation sidebar", "message": "Registration tokens" @@ -179,6 +183,270 @@ "description": "Title for the login page", "message": "Login" }, + "pages.personal_tokens.acting_user_column": { + "description": "Column header for acting user column", + "message": "Acting User" + }, + "pages.personal_tokens.acting_user_label": { + "description": "Label for the acting user field", + "message": "Acting user" + }, + "pages.personal_tokens.actor_user_help": { + "description": "Help text for the acting user field", + "message": "The user this token will act on behalf of" + }, + "pages.personal_tokens.actor_user_label": { + "description": "Label for the acting user field", + "message": "Acting user" + }, + "pages.personal_tokens.add_token_title": { + "description": "Title of the add personal token dialog", + "message": "Add personal token" + }, + "pages.personal_tokens.cant_regenerate_tooltip": { + "description": "Personal tokens can only be regenerated by the owner of said token. This is the tooltip explaining that on the disabled 'Regenerate token' button when the owner is another user", + "message": "Only {ownerMxid} can regenerate this token" + }, + "pages.personal_tokens.cant_regenerate_tooltip_generic": { + "description": "Personal tokens can only be regenerated by the owner of said token. This is the tooltip explaining that on the disabled 'Regenerate token' button when the owner is a client", + "message": "Only the owner of the token can regenerate it" + }, + "pages.personal_tokens.count": { + "description": "Shows the number of personal tokens", + "message": "{count, plural, =0 {No personal tokens} one {# personal token} other {# personal tokens}}" + }, + "pages.personal_tokens.create_error": { + "description": "Error message when creating a personal token fails", + "message": "Failed to create personal token" + }, + "pages.personal_tokens.create_success": { + "description": "Success message when a personal token is created", + "message": "Personal token created successfully" + }, + "pages.personal_tokens.create_token": { + "description": "Button text to create a personal token", + "message": "Create token" + }, + "pages.personal_tokens.created_at_label": { + "description": "Label for the token creation date field", + "message": "Created at" + }, + "pages.personal_tokens.description": { + "description": "The description of the personal tokens list page", + "message": "Personal tokens are long-lived access tokens with specific access, including Synapse and MAS administration API access. They are useful for automating tasks and for creating integrations." + }, + "pages.personal_tokens.device_id_help": { + "description": "Help text for device ID field", + "message": "Leave empty to generate a random 10-character device ID" + }, + "pages.personal_tokens.device_id_label": { + "description": "Label for device ID field", + "message": "Device ID" + }, + "pages.personal_tokens.device_id_placeholder": { + "description": "Placeholder for device ID field", + "message": "ABCDEFGHIJ" + }, + "pages.personal_tokens.expires_at_column": { + "description": "Column header for expires at column", + "message": "Expires at" + }, + "pages.personal_tokens.expires_at_label": { + "description": "Label for the token expiration date field", + "message": "Expires at" + }, + "pages.personal_tokens.expires_in_help": { + "description": "Help text for the expiry field", + "message": "Leave empty for tokens that never expire" + }, + "pages.personal_tokens.expires_in_label": { + "description": "Label for the expiry field", + "message": "Expires in (days)" + }, + "pages.personal_tokens.expires_in_placeholder": { + "description": "Placeholder for the expiry field", + "message": "30" + }, + "pages.personal_tokens.filter.active": { + "description": "Filter label for active personal tokens", + "message": "Active" + }, + "pages.personal_tokens.filter.expires": { + "description": "Filter label for tokens that expire", + "message": "With an expiry date" + }, + "pages.personal_tokens.filter.no_expiry": { + "description": "Filter label for tokens that never expire", + "message": "Never expires" + }, + "pages.personal_tokens.filter.revoked": { + "description": "Filter label for revoked personal tokens", + "message": "Revoked" + }, + "pages.personal_tokens.filter.scope_mas_admin": { + "description": "Filter label for MAS admin scope", + "message": "Access to the MAS admin API" + }, + "pages.personal_tokens.filter.scope_matrix_client": { + "description": "Filter label for Matrix Client API scope", + "message": "Access to the Matrix Client-Server API" + }, + "pages.personal_tokens.filter.scope_synapse_admin": { + "description": "Filter label for synapse admin scope", + "message": "Access to the Synapse admin API" + }, + "pages.personal_tokens.last_active_column": { + "description": "Column header for last active column", + "message": "Last Active" + }, + "pages.personal_tokens.last_active_ip_label": { + "description": "Label for the last active IP field", + "message": "Last active IP" + }, + "pages.personal_tokens.last_active_label": { + "description": "Label for the last active date field", + "message": "Last active" + }, + "pages.personal_tokens.loading_more": { + "description": "Text shown when loading more tokens", + "message": "Loading more tokens..." + }, + "pages.personal_tokens.name_column": { + "description": "Column header for token name column", + "message": "Name" + }, + "pages.personal_tokens.name_help": { + "description": "Help text for the token name field", + "message": "A human-readable name for the token, to help you identify it" + }, + "pages.personal_tokens.name_label": { + "description": "Label for the personal token name field", + "message": "Token name" + }, + "pages.personal_tokens.name_placeholder": { + "description": "Placeholder for the personal token name field", + "message": "My application token" + }, + "pages.personal_tokens.never_expires": { + "description": "Text shown when a token has no expiration date", + "message": "Never expires" + }, + "pages.personal_tokens.never_used": { + "description": "Text shown when a token has never been used", + "message": "Never used" + }, + "pages.personal_tokens.not_found.description": { + "description": "Description shown when a personal token is not found", + "message": "The personal token you're looking for doesn't exist or has been removed." + }, + "pages.personal_tokens.not_found.title": { + "description": "Title shown when a personal token is not found", + "message": "Personal token not found" + }, + "pages.personal_tokens.owner_user_label": { + "description": "Label for the owner user field", + "message": "Owner" + }, + "pages.personal_tokens.regenerate_confirm": { + "description": "Button text to confirm regenerating a personal token", + "message": "Regenerate token" + }, + "pages.personal_tokens.regenerate_error": { + "description": "Error message when regenerating a personal token fails", + "message": "Failed to regenerate personal token" + }, + "pages.personal_tokens.regenerate_expires_help": { + "description": "Help text for the expiry field when regenerating", + "message": "Leave empty to keep the same expiry as before, or set a new expiry time" + }, + "pages.personal_tokens.regenerate_success": { + "description": "Success message when a personal token is regenerated", + "message": "Personal token regenerated successfully" + }, + "pages.personal_tokens.regenerate_token": { + "description": "Button text to regenerate a token", + "message": "Regenerate token" + }, + "pages.personal_tokens.regenerate_token_title": { + "description": "Title of the regenerate personal token dialog", + "message": "Regenerate personal token" + }, + "pages.personal_tokens.regenerate_warning": { + "description": "Warning message shown when regenerating a personal token", + "message": "This will generate a new access token and invalidate the current one. Any applications using the current token will need to be updated." + }, + "pages.personal_tokens.revoke_success": { + "description": "Success message when a personal token is revoked", + "message": "Personal token revoked successfully" + }, + "pages.personal_tokens.revoke_token": { + "description": "Button text to revoke a token", + "message": "Revoke token" + }, + "pages.personal_tokens.revoked_at_label": { + "description": "Label for the token revocation date field", + "message": "Revoked at" + }, + "pages.personal_tokens.scope_device_help": { + "description": "Help text for device scope", + "message": "Provision a Matrix device" + }, + "pages.personal_tokens.scope_mas_admin_help": { + "description": "Help text for MAS admin scope", + "message": "Access to the MAS admin API" + }, + "pages.personal_tokens.scope_matrix_client_help": { + "description": "Help text for Matrix Client API scope", + "message": "Access to the Matrix Client-Server API" + }, + "pages.personal_tokens.scope_synapse_admin_help": { + "description": "Help text for Synapse admin scope", + "message": "Access to the Synapse admin API" + }, + "pages.personal_tokens.scopes_label": { + "description": "Label for the scopes field", + "message": "Scopes" + }, + "pages.personal_tokens.status.active": { + "description": "Status badge for active personal tokens", + "message": "Active" + }, + "pages.personal_tokens.status.column": { + "description": "Column header for status column", + "message": "Status" + }, + "pages.personal_tokens.status.expired": { + "description": "Status badge for expired personal tokens", + "message": "Expired" + }, + "pages.personal_tokens.status.label": { + "description": "Label for the personal token status field", + "message": "Status" + }, + "pages.personal_tokens.status.revoked": { + "description": "Status badge for revoked personal tokens", + "message": "Revoked" + }, + "pages.personal_tokens.title": { + "description": "The title of the personal tokens list page", + "message": "Personal tokens" + }, + "pages.personal_tokens.token_created_description": { + "description": "Description shown when a personal token is created", + "message": "Your personal token has been created. Copy it now as it will not be shown again." + }, + "pages.personal_tokens.token_created_title": { + "description": "Title of the dialog when a personal token is successfully created", + "message": "Personal token created" + }, + "pages.personal_tokens.token_regenerated_description": { + "description": "Description shown when a personal token is regenerated", + "message": "Your personal token has been regenerated. Copy it now as it will not be shown again." + }, + "pages.personal_tokens.token_regenerated_title": { + "description": "Title of the dialog when a personal token is successfully regenerated", + "message": "Token regenerated" + }, "pages.registration_tokens.auto_generate_placeholder": { "description": "Placeholder text for custom token field", "message": "Auto-generate if left empty" @@ -766,5 +1034,17 @@ "ui.language_switcher.title": { "description": "Title of the language switcher modal", "message": "Language settings" + }, + "ui.user_picker.loading_list": { + "description": "On the user-picker list, this is the placeholder shown whilst things are loading", + "message": "Loading…" + }, + "ui.user_picker.no_match": { + "description": "On the user-picker list, this is the placeholder shown when there are no results", + "message": "No match" + }, + "ui.user_picker.no_match_help": { + "description": "On the user-picker list, this is the help text shown when there are no results", + "message": "Try a different search term. This can only lookup active users on {serverName} using their localpart." } }