feat(v1.1.8): dashboard Users tab + Invitations sub-tab

apps/[slug]/users/+page.svelte: list, create form, edit modal,
revoke-sessions, reset-password (returns one-shot token in a copy
modal so the admin can paste it into a manual reset link), delete.

apps/[slug]/users/invitations/+page.svelte: pending list, create
form (with optional inline email template — off by default for
out-of-band delivery), revoke.

Tab strip in apps/[slug]/+page.svelte gets a Users entry above
Files, matching the external-route pattern files/ and dead-letters/
already use. Sub-tab navigation from Users -> Invitations and back.

api.ts gains AppUser / Invitation / ResetPasswordResponse /
CreateAppUserInput / PatchAppUserInput / InvitationTemplate /
CreateInvitationInput types and `users` + `appInvitations`
namespaces mirroring the appMembers shape.

svelte-check is green on every file under src/. The 150 errors
the runner reports are all pre-existing in tests/e2e/ (missing
@playwright/test install — unrelated to v1.1.8 changes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-06 14:54:20 +02:00
parent aa2631ff61
commit 6449cb6f6a
4 changed files with 918 additions and 0 deletions

View File

@@ -238,6 +238,68 @@ export interface CreateEmailTriggerInput {
inbound_secret?: string | null;
}
/// v1.1.8 per-app end-user record returned by the admin users API.
/// Never carries the password hash or session tokens.
export interface AppUser {
id: string;
app_id: string;
email: string;
display_name: string | null;
email_verified_at: string | null;
last_login_at: string | null;
created_at: string;
updated_at: string;
roles: string[];
}
export interface CreateAppUserInput {
email: string;
password: string;
display_name?: string | null;
}
export interface PatchAppUserInput {
/// Triple-state: present-with-string sets, present-with-null clears,
/// absent leaves alone. The serde shape on the server is the same.
display_name?: string | null;
}
export interface ResetPasswordResponse {
token: string;
expires_in_seconds: number;
}
export interface RevokeSessionsResponse {
revoked: number;
}
/// v1.1.8 pending-invitation row.
export interface Invitation {
id: string;
app_id: string;
email: string;
display_name: string | null;
roles: string[];
created_at: string;
expires_at: string;
}
export interface InvitationTemplate {
link_base: string;
from: string;
subject: string;
body_template: string;
}
export interface CreateInvitationInput {
email: string;
display_name?: string | null;
roles?: string[];
/// Optional. Omit to issue an invitation without sending an email
/// (the admin delivers the token out-of-band).
template?: InvitationTemplate;
}
/// v1.1.5 file metadata as the admin files endpoint returns it.
export interface FileMeta {
id: string;
@@ -760,6 +822,64 @@ export const api = {
)
},
users: {
list: (idOrSlug: string, opts: { cursor?: string; limit?: number } = {}) => {
const params = new URLSearchParams();
if (opts.cursor) params.set('cursor', opts.cursor);
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
const qs = params.toString();
return adminRequest<{ users: AppUser[]; next_cursor: string | null }>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users${qs ? `?${qs}` : ''}`
);
},
get: (idOrSlug: string, userId: string) =>
adminRequest<AppUser>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users/${userId}`
),
create: (idOrSlug: string, input: CreateAppUserInput) =>
adminRequest<AppUser>(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users`, {
method: 'POST',
body: JSON.stringify(input)
}),
patch: (idOrSlug: string, userId: string, input: PatchAppUserInput) =>
adminRequest<AppUser>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users/${userId}`,
{ method: 'PATCH', body: JSON.stringify(input) }
),
remove: (idOrSlug: string, userId: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users/${userId}`,
{ method: 'DELETE' }
),
resetPassword: (idOrSlug: string, userId: string) =>
adminRequest<ResetPasswordResponse>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users/${userId}/reset-password`,
{ method: 'POST' }
),
revokeSessions: (idOrSlug: string, userId: string) =>
adminRequest<RevokeSessionsResponse>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users/${userId}/revoke-sessions`,
{ method: 'POST' }
)
},
appInvitations: {
list: (idOrSlug: string) =>
adminRequest<{ invitations: Invitation[] }>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/invitations`
),
create: (idOrSlug: string, input: CreateInvitationInput) =>
adminRequest<Invitation>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/invitations`,
{ method: 'POST', body: JSON.stringify(input) }
),
remove: (idOrSlug: string, inviteId: string) =>
adminRequest<null>(
`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/invitations/${inviteId}`,
{ method: 'DELETE' }
)
},
execute: async (
id: string,
body: unknown,