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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user