From 6449cb6f6a91c581cf7e4bb3494ed4c100fa4962 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 6 Jun 2026 14:54:20 +0200 Subject: [PATCH] feat(v1.1.8): dashboard Users tab + Invitations sub-tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- dashboard/src/lib/api.ts | 120 +++++ dashboard/src/routes/apps/[slug]/+page.svelte | 7 + .../src/routes/apps/[slug]/users/+page.svelte | 466 ++++++++++++++++++ .../[slug]/users/invitations/+page.svelte | 325 ++++++++++++ 4 files changed, 918 insertions(+) create mode 100644 dashboard/src/routes/apps/[slug]/users/+page.svelte create mode 100644 dashboard/src/routes/apps/[slug]/users/invitations/+page.svelte diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index 7e0a8cd..8e5ce36 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -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( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users/${userId}` + ), + create: (idOrSlug: string, input: CreateAppUserInput) => + adminRequest(`/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users`, { + method: 'POST', + body: JSON.stringify(input) + }), + patch: (idOrSlug: string, userId: string, input: PatchAppUserInput) => + adminRequest( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users/${userId}`, + { method: 'PATCH', body: JSON.stringify(input) } + ), + remove: (idOrSlug: string, userId: string) => + adminRequest( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users/${userId}`, + { method: 'DELETE' } + ), + resetPassword: (idOrSlug: string, userId: string) => + adminRequest( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/users/${userId}/reset-password`, + { method: 'POST' } + ), + revokeSessions: (idOrSlug: string, userId: string) => + adminRequest( + `/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( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/invitations`, + { method: 'POST', body: JSON.stringify(input) } + ), + remove: (idOrSlug: string, inviteId: string) => + adminRequest( + `/api/v1/admin/apps/${encodeURIComponent(idOrSlug)}/invitations/${inviteId}`, + { method: 'DELETE' } + ) + }, + execute: async ( id: string, body: unknown, diff --git a/dashboard/src/routes/apps/[slug]/+page.svelte b/dashboard/src/routes/apps/[slug]/+page.svelte index 8b49491..1b5ab75 100644 --- a/dashboard/src/routes/apps/[slug]/+page.svelte +++ b/dashboard/src/routes/apps/[slug]/+page.svelte @@ -783,6 +783,13 @@ class:active={activeTab === 'settings'} onclick={() => (activeTab = 'settings')}>Settings + + Users + + import { base } from '$app/paths'; + import { page } from '$app/state'; + import { + api, + ApiError, + type App, + type AppUser, + type ResetPasswordResponse + } from '$lib/api'; + import ConfirmModal from '$lib/ConfirmModal.svelte'; + + let slug = $derived(page.params.slug ?? ''); + let app = $state(null); + let users = $state([]); + let nextCursor = $state(null); + let loading = $state(false); + let error = $state(null); + + let showCreate = $state(false); + let createEmail = $state(''); + let createPassword = $state(''); + let createDisplayName = $state(''); + let creating = $state(false); + + let editing = $state(null); + let editDisplayName = $state(''); + let editRolesText = $state(''); + let savingEdit = $state(false); + + let toRemove = $state(null); + let removing = $state(false); + + let toRevoke = $state(null); + let revoking = $state(false); + + let toReset = $state(null); + let resetToken = $state(null); + let resetting = $state(false); + + async function loadApp() { + try { + app = await api.apps.get(slug); + } catch (e) { + error = e instanceof ApiError ? e.message : String(e); + } + } + + async function loadUsers(cursor?: string) { + loading = true; + error = null; + try { + const res = await api.users.list(slug, { cursor, limit: 100 }); + if (cursor) { + users = [...users, ...res.users]; + } else { + users = res.users; + } + nextCursor = res.next_cursor; + } catch (e) { + error = e instanceof ApiError ? e.message : String(e); + } finally { + loading = false; + } + } + + $effect(() => { + void slug; + void loadApp(); + void loadUsers(); + }); + + async function submitCreate() { + creating = true; + error = null; + try { + const created = await api.users.create(slug, { + email: createEmail.trim(), + password: createPassword, + display_name: createDisplayName.trim() || null + }); + users = [created, ...users]; + createEmail = ''; + createPassword = ''; + createDisplayName = ''; + showCreate = false; + } catch (e) { + error = e instanceof ApiError ? e.message : String(e); + } finally { + creating = false; + } + } + + function openEdit(u: AppUser) { + editing = u; + editDisplayName = u.display_name ?? ''; + // Roles are managed via SDK / cron / admin-issued invitations + // only for v1.1.8; the dashboard renders them read-only here. + editRolesText = u.roles.join(', '); + } + + async function submitEdit() { + if (!editing) return; + savingEdit = true; + try { + const trimmed = editDisplayName.trim(); + const updated = await api.users.patch(slug, editing.id, { + display_name: trimmed.length ? trimmed : null + }); + users = users.map((u) => (u.id === updated.id ? updated : u)); + editing = null; + } catch (e) { + error = e instanceof ApiError ? e.message : String(e); + } finally { + savingEdit = false; + } + } + + async function confirmRemove() { + if (!toRemove) return; + removing = true; + try { + await api.users.remove(slug, toRemove.id); + users = users.filter((u) => u.id !== toRemove!.id); + toRemove = null; + } catch (e) { + error = e instanceof ApiError ? e.message : String(e); + } finally { + removing = false; + } + } + + async function confirmRevoke() { + if (!toRevoke) return; + revoking = true; + try { + await api.users.revokeSessions(slug, toRevoke.id); + toRevoke = null; + } catch (e) { + error = e instanceof ApiError ? e.message : String(e); + } finally { + revoking = false; + } + } + + async function confirmReset() { + if (!toReset) return; + resetting = true; + try { + resetToken = await api.users.resetPassword(slug, toReset.id); + } catch (e) { + error = e instanceof ApiError ? e.message : String(e); + } finally { + resetting = false; + } + } + + function closeResetTokenModal() { + toReset = null; + resetToken = null; + } + + function fmtTime(iso: string | null): string { + return iso ? new Date(iso).toLocaleString() : '—'; + } + + + + Users · {slug} · PiCloud + + +
+
+
+ ← back to {app?.name ?? slug} +

Users

+

+ End-users of this app, managed by the users::* SDK. Distinct + from instance operators (Admins tab). +

+
+
+ Invitations + +
+
+ + {#if showCreate} +
{ + e.preventDefault(); + void submitCreate(); + }} + > + + + + +
+ {/if} + + {#if error} +
{error}
+ {/if} + + {#if users.length === 0 && !loading} +

No users yet. Create one above or wait for them to sign up.

+ {:else} + + + + + + + + + + + + + {#each users as u (u.id)} + + + + + + + + + {/each} + +
EmailDisplay nameVerifiedRolesLast login
{u.email}{u.display_name ?? '—'} + {#if u.email_verified_at} + yes + {:else} + pending + {/if} + + {#each u.roles as r} + {r} + {/each} + {fmtTime(u.last_login_at)} + + + + +
+ {#if nextCursor} + + {/if} + {/if} +
+ +{#if editing} + (editing = null)} + > + + + + +{/if} + +{#if toRemove} + (toRemove = null)} + > +

+ Delete {toRemove.email}? This removes their record, sessions, roles, + and pending reset / verification tokens. Cannot be undone. +

+
+{/if} + +{#if toRevoke} + (toRevoke = null)} + > +

+ Revoke every active session for {toRevoke.email}? They will be + signed out everywhere immediately; the next request requires a fresh login. +

+
+{/if} + +{#if toReset && !resetToken} + (toReset = null)} + > +

+ Generate a one-shot password-reset token for {toReset.email}? + The token is returned exactly once — paste it into a manual reset link. +

+
+{/if} + +{#if resetToken} + +

+ Copy this token now — it won't be shown again. It expires in + {Math.round(resetToken.expires_in_seconds / 60)} minutes. +

+
{resetToken.token}
+
+{/if} + + diff --git a/dashboard/src/routes/apps/[slug]/users/invitations/+page.svelte b/dashboard/src/routes/apps/[slug]/users/invitations/+page.svelte new file mode 100644 index 0000000..77e90e8 --- /dev/null +++ b/dashboard/src/routes/apps/[slug]/users/invitations/+page.svelte @@ -0,0 +1,325 @@ + + + + Invitations · {slug} · PiCloud + + +
+
+
+ ← back to Users +

Invitations

+

+ Pending invitations. Accepting an invitation creates the user and signs them in + with a fresh session token; pre-staged roles are applied atomically. +

+
+
+ +
+
+ + {#if showCreate} +
{ + e.preventDefault(); + void submitCreate(); + }} + > + + + + + + + {#if sendEmail} + + + + + {/if} + + +
+ {/if} + + {#if error} +
{error}
+ {/if} + + {#if loading} +

Loading…

+ {:else if invitations.length === 0} +

No pending invitations.

+ {:else} + + + + + + + + + + + + + {#each invitations as inv (inv.id)} + + + + + + + + + {/each} + +
EmailDisplay nameRolesCreatedExpires
{inv.email}{inv.display_name ?? '—'} + {#each inv.roles as r} + {r} + {/each} + {fmtTime(inv.created_at)}{fmtTime(inv.expires_at)} + +
+ {/if} +
+ +{#if toRevoke} + (toRevoke = null)} + > +

+ Revoke pending invitation for {toRevoke.email}? The token sent + to them becomes inert immediately. +

+
+{/if} + +