feat(dashboard): users admin page with invite/edit/delete + password reveal
/admin/users is the owner+admin surface for managing the platform's user list. Members get bounced to /profile?denied=users. Invite generates a random 16-char password client-side, POSTs the new user, and surfaces the cleartext exactly once in a yellow- bordered reveal modal with a Copy button and an "I've shared it" acknowledgement gate. Owner role is intentionally not in the create form — promote via Edit after creation, matching the backend's deliberate-step comment. Edit handles username, email, role (with affordance hiding: admins see admin/member only), is_active toggle, and a separate "Reset password" button that re-uses the same reveal flow. Delete uses ConfirmModal with confirmPhrase=username and explains the last-owner/last-admin 422s up front. Username + email validated client-side against the same patterns the backend enforces so the form fails fast rather than always on the round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
980
dashboard/src/routes/users/+page.svelte
Normal file
980
dashboard/src/routes/users/+page.svelte
Normal file
@@ -0,0 +1,980 @@
|
|||||||
|
<!--
|
||||||
|
/admin/users — owner + admin only. Members get bounced to /profile
|
||||||
|
with ?denied=users. Replaces the pre-3.5 /admin/admins page; this
|
||||||
|
one knows about roles, email, and the last-owner/last-admin guards.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
ApiError,
|
||||||
|
type AdminDto,
|
||||||
|
type InstanceRole
|
||||||
|
} from '$lib/api';
|
||||||
|
import { currentUser } from '$lib/auth';
|
||||||
|
import RoleChip from '$lib/RoleChip.svelte';
|
||||||
|
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||||
|
import { generatePassword } from '$lib/password-gen';
|
||||||
|
|
||||||
|
const me = $derived($currentUser);
|
||||||
|
const myRole = $derived(me?.instance_role);
|
||||||
|
const isOwner = $derived(myRole === 'owner');
|
||||||
|
|
||||||
|
// Member guard. The backend already 403s the list call, but
|
||||||
|
// surfacing a friendly redirect avoids the dead-end empty page.
|
||||||
|
$effect(() => {
|
||||||
|
if (me && me.instance_role === 'member') {
|
||||||
|
void goto(`${base}/profile?denied=users`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let admins = $state<AdminDto[]>([]);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let banner = $state<{ kind: 'error' | 'info'; message: string } | null>(null);
|
||||||
|
|
||||||
|
let search = $state('');
|
||||||
|
const filtered = $derived(
|
||||||
|
(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return admins;
|
||||||
|
return admins.filter(
|
||||||
|
(a) =>
|
||||||
|
a.username.toLowerCase().includes(q) ||
|
||||||
|
(a.email ?? '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Invite (create) modal --------------------------------------------------
|
||||||
|
let inviteOpen = $state(false);
|
||||||
|
let inviteForm = $state<{ username: string; email: string; instance_role: 'admin' | 'member' }>({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
instance_role: 'admin'
|
||||||
|
});
|
||||||
|
let invitePending = $state(false);
|
||||||
|
let inviteError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// One-time password reveal (used by both invite + reset)
|
||||||
|
let revealPassword = $state<string | null>(null);
|
||||||
|
let revealForUsername = $state<string>('');
|
||||||
|
let revealKind = $state<'invite' | 'reset'>('invite');
|
||||||
|
let revealAck = $state(false);
|
||||||
|
let copyState = $state<'idle' | 'copied'>('idle');
|
||||||
|
|
||||||
|
// Edit modal -------------------------------------------------------------
|
||||||
|
let editTarget = $state<AdminDto | null>(null);
|
||||||
|
let editForm = $state<{
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
instance_role: InstanceRole;
|
||||||
|
is_active: boolean;
|
||||||
|
}>({ username: '', email: '', instance_role: 'admin', is_active: true });
|
||||||
|
let editPending = $state(false);
|
||||||
|
let editError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Delete modal -----------------------------------------------------------
|
||||||
|
let deleteTarget = $state<AdminDto | null>(null);
|
||||||
|
let deletePending = $state(false);
|
||||||
|
|
||||||
|
// Validation rules (mirror backend: 2-32, [a-z0-9._-]) -------------------
|
||||||
|
const USERNAME_RE = /^[a-z0-9._-]{2,32}$/;
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
const inviteUsernameValid = $derived(USERNAME_RE.test(inviteForm.username));
|
||||||
|
const inviteEmailValid = $derived(
|
||||||
|
inviteForm.email.trim() === '' || EMAIL_RE.test(inviteForm.email.trim())
|
||||||
|
);
|
||||||
|
const canInvite = $derived(inviteUsernameValid && inviteEmailValid && !invitePending);
|
||||||
|
|
||||||
|
const editUsernameValid = $derived(USERNAME_RE.test(editForm.username));
|
||||||
|
const editEmailValid = $derived(
|
||||||
|
editForm.email.trim() === '' || EMAIL_RE.test(editForm.email.trim())
|
||||||
|
);
|
||||||
|
const canSubmitEdit = $derived(editUsernameValid && editEmailValid && !editPending);
|
||||||
|
|
||||||
|
// Admin (non-owner) cannot touch owner rows for delete or role demote.
|
||||||
|
function canDelete(row: AdminDto): boolean {
|
||||||
|
if (isOwner) return true;
|
||||||
|
return row.instance_role !== 'owner';
|
||||||
|
}
|
||||||
|
|
||||||
|
const editRoleOptions = $derived<InstanceRole[]>(
|
||||||
|
isOwner ? ['owner', 'admin', 'member'] : ['admin', 'member']
|
||||||
|
);
|
||||||
|
|
||||||
|
onMount(refresh);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loadError = null;
|
||||||
|
try {
|
||||||
|
admins = await api.admins.list();
|
||||||
|
} catch (e) {
|
||||||
|
loadError = e instanceof ApiError ? e.message : 'failed to load users';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash(kind: 'error' | 'info', message: string) {
|
||||||
|
banner = { kind, message };
|
||||||
|
setTimeout(() => {
|
||||||
|
if (banner?.message === message) banner = null;
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openInvite() {
|
||||||
|
inviteForm = { username: '', email: '', instance_role: 'admin' };
|
||||||
|
inviteError = null;
|
||||||
|
inviteOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitInvite(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canInvite) return;
|
||||||
|
invitePending = true;
|
||||||
|
inviteError = null;
|
||||||
|
const password = generatePassword(16);
|
||||||
|
try {
|
||||||
|
const created = await api.admins.create({
|
||||||
|
username: inviteForm.username,
|
||||||
|
password,
|
||||||
|
instance_role: inviteForm.instance_role,
|
||||||
|
email: inviteForm.email.trim() === '' ? null : inviteForm.email.trim()
|
||||||
|
});
|
||||||
|
admins = [...admins, created].sort((a, b) => a.username.localeCompare(b.username));
|
||||||
|
inviteOpen = false;
|
||||||
|
revealPassword = password;
|
||||||
|
revealForUsername = created.username;
|
||||||
|
revealKind = 'invite';
|
||||||
|
revealAck = false;
|
||||||
|
copyState = 'idle';
|
||||||
|
} catch (e) {
|
||||||
|
inviteError = e instanceof ApiError ? e.message : 'failed to create user';
|
||||||
|
} finally {
|
||||||
|
invitePending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: AdminDto) {
|
||||||
|
editTarget = row;
|
||||||
|
editForm = {
|
||||||
|
username: row.username,
|
||||||
|
email: row.email ?? '',
|
||||||
|
instance_role: row.instance_role,
|
||||||
|
is_active: row.is_active
|
||||||
|
};
|
||||||
|
editError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEdit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!editTarget || !canSubmitEdit) return;
|
||||||
|
editPending = true;
|
||||||
|
editError = null;
|
||||||
|
const patch: {
|
||||||
|
username?: string;
|
||||||
|
email?: string | null;
|
||||||
|
instance_role?: InstanceRole;
|
||||||
|
is_active?: boolean;
|
||||||
|
} = {};
|
||||||
|
if (editForm.username !== editTarget.username) patch.username = editForm.username;
|
||||||
|
if ((editTarget.email ?? '') !== editForm.email.trim()) {
|
||||||
|
patch.email = editForm.email.trim() === '' ? null : editForm.email.trim();
|
||||||
|
}
|
||||||
|
if (editForm.instance_role !== editTarget.instance_role) {
|
||||||
|
patch.instance_role = editForm.instance_role;
|
||||||
|
}
|
||||||
|
if (editForm.is_active !== editTarget.is_active) patch.is_active = editForm.is_active;
|
||||||
|
try {
|
||||||
|
const updated = await api.admins.update(editTarget.id, patch);
|
||||||
|
admins = admins
|
||||||
|
.map((a) => (a.id === updated.id ? updated : a))
|
||||||
|
.sort((a, b) => a.username.localeCompare(b.username));
|
||||||
|
const name = updated.username;
|
||||||
|
editTarget = null;
|
||||||
|
flash('info', `Updated "${name}".`);
|
||||||
|
} catch (e) {
|
||||||
|
editError = e instanceof ApiError ? e.message : 'failed to update user';
|
||||||
|
} finally {
|
||||||
|
editPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetPassword() {
|
||||||
|
if (!editTarget) return;
|
||||||
|
const target = editTarget;
|
||||||
|
const password = generatePassword(16);
|
||||||
|
editPending = true;
|
||||||
|
editError = null;
|
||||||
|
try {
|
||||||
|
await api.admins.update(target.id, { password });
|
||||||
|
editTarget = null;
|
||||||
|
revealPassword = password;
|
||||||
|
revealForUsername = target.username;
|
||||||
|
revealKind = 'reset';
|
||||||
|
revealAck = false;
|
||||||
|
copyState = 'idle';
|
||||||
|
} catch (e) {
|
||||||
|
editError = e instanceof ApiError ? e.message : 'failed to reset password';
|
||||||
|
} finally {
|
||||||
|
editPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleActive(row: AdminDto) {
|
||||||
|
try {
|
||||||
|
const updated = await api.admins.update(row.id, { is_active: !row.is_active });
|
||||||
|
admins = admins.map((a) => (a.id === updated.id ? updated : a));
|
||||||
|
flash(
|
||||||
|
'info',
|
||||||
|
`${updated.username} ${updated.is_active ? 'reactivated' : 'deactivated'}.`
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
flash('error', e instanceof ApiError ? e.message : 'failed to update user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDelete(row: AdminDto) {
|
||||||
|
deleteTarget = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
deletePending = true;
|
||||||
|
const target = deleteTarget;
|
||||||
|
try {
|
||||||
|
await api.admins.remove(target.id);
|
||||||
|
deleteTarget = null;
|
||||||
|
if (me && me.id === target.id) {
|
||||||
|
// Self-delete: bail out to login.
|
||||||
|
await api.auth.logout();
|
||||||
|
await goto(`${base}/login`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
admins = admins.filter((a) => a.id !== target.id);
|
||||||
|
flash('info', `Deleted "${target.username}".`);
|
||||||
|
} catch (e) {
|
||||||
|
flash('error', e instanceof ApiError ? e.message : 'failed to delete user');
|
||||||
|
} finally {
|
||||||
|
deletePending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPassword() {
|
||||||
|
if (!revealPassword) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(revealPassword);
|
||||||
|
copyState = 'copied';
|
||||||
|
setTimeout(() => (copyState = 'idle'), 2000);
|
||||||
|
} catch {
|
||||||
|
flash('error', 'Clipboard write failed — select and copy manually.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissReveal() {
|
||||||
|
revealPassword = null;
|
||||||
|
revealAck = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relative(iso: string | null): string {
|
||||||
|
if (!iso) return 'Never';
|
||||||
|
const sec = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
|
||||||
|
if (sec < 60) return `${sec}s ago`;
|
||||||
|
const min = Math.round(sec / 60);
|
||||||
|
if (min < 60) return `${min}m ago`;
|
||||||
|
const hr = Math.round(min / 60);
|
||||||
|
if (hr < 24) return `${hr}h ago`;
|
||||||
|
const day = Math.round(hr / 24);
|
||||||
|
if (day < 7) return `${day}d ago`;
|
||||||
|
return new Date(iso).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortDate(iso: string): string {
|
||||||
|
return new Date(iso).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="head">
|
||||||
|
<h1>Users</h1>
|
||||||
|
<div class="head-controls">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search by username or email…"
|
||||||
|
bind:value={search}
|
||||||
|
class="search"
|
||||||
|
/>
|
||||||
|
<button type="button" class="primary" onclick={openInvite}>+ Invite user</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if banner}
|
||||||
|
<div class="banner banner-{banner.kind}">{banner.message}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<div class="error">
|
||||||
|
{loadError}
|
||||||
|
<button type="button" class="retry" onclick={refresh}>Retry</button>
|
||||||
|
</div>
|
||||||
|
{:else if admins.length === 0}
|
||||||
|
<p class="empty">No users yet. Invite one to get started.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="table">
|
||||||
|
<div class="row head-row">
|
||||||
|
<div>Username</div>
|
||||||
|
<div>Role</div>
|
||||||
|
<div>Email</div>
|
||||||
|
<div>Status</div>
|
||||||
|
<div>Created</div>
|
||||||
|
<div>Last login</div>
|
||||||
|
<div class="actions-col"></div>
|
||||||
|
</div>
|
||||||
|
{#each filtered as row (row.id)}
|
||||||
|
<div class="row">
|
||||||
|
<div class="name-cell">
|
||||||
|
<span class="name">{row.username}</span>
|
||||||
|
{#if me && me.id === row.id}
|
||||||
|
<span class="you-tag">(you)</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div><RoleChip role={row.instance_role} size="sm" /></div>
|
||||||
|
<div class="email-cell">{row.email ?? '—'}</div>
|
||||||
|
<div>
|
||||||
|
{#if row.is_active}
|
||||||
|
<span class="status status-active">● Active</span>
|
||||||
|
{:else}
|
||||||
|
<span class="status status-inactive">○ Inactive</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div>{shortDate(row.created_at)}</div>
|
||||||
|
<div title={row.last_login_at ?? ''}>{relative(row.last_login_at)}</div>
|
||||||
|
<div class="actions-col">
|
||||||
|
<button type="button" class="row-action" onclick={() => openEdit(row)}>Edit</button>
|
||||||
|
<button type="button" class="row-action" onclick={() => toggleActive(row)}>
|
||||||
|
{row.is_active ? 'Deactivate' : 'Reactivate'}
|
||||||
|
</button>
|
||||||
|
{#if canDelete(row)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="row-action danger-link"
|
||||||
|
onclick={() => openDelete(row)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#if filtered.length === 0 && admins.length > 0}
|
||||||
|
<div class="row empty-row">No matches for "{search}".</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Invite modal -->
|
||||||
|
{#if inviteOpen}
|
||||||
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget && !invitePending) inviteOpen = false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form class="modal" onsubmit={submitInvite}>
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>Invite user</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="x"
|
||||||
|
aria-label="Close"
|
||||||
|
disabled={invitePending}
|
||||||
|
onclick={() => (inviteOpen = false)}>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p class="modal-intro">
|
||||||
|
A random password will be generated and shown to you exactly once. PiCloud cannot send
|
||||||
|
email — copy and share through your own channel.
|
||||||
|
</p>
|
||||||
|
<label class="field">
|
||||||
|
<span>Username</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value={inviteForm.username}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<small>2–32 chars. Lowercase letters, digits, <code>.</code> <code>_</code> <code>-</code>.</small>
|
||||||
|
{#if inviteForm.username && !inviteUsernameValid}
|
||||||
|
<small class="invalid">Doesn't match the allowed pattern.</small>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Email <span class="opt">(optional)</span></span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value={inviteForm.email}
|
||||||
|
/>
|
||||||
|
{#if !inviteEmailValid}
|
||||||
|
<small class="invalid">Doesn't look like an email address.</small>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<fieldset class="field">
|
||||||
|
<legend>Role</legend>
|
||||||
|
<label class="radio">
|
||||||
|
<input type="radio" bind:group={inviteForm.instance_role} value="admin" />
|
||||||
|
<span>Admin — can manage users, scripts, and all apps.</span>
|
||||||
|
</label>
|
||||||
|
<label class="radio">
|
||||||
|
<input type="radio" bind:group={inviteForm.instance_role} value="member" />
|
||||||
|
<span>Member — only sees apps they're added to.</span>
|
||||||
|
</label>
|
||||||
|
<small>
|
||||||
|
Owners can't be created here — promote via Edit after creation.
|
||||||
|
</small>
|
||||||
|
</fieldset>
|
||||||
|
{#if inviteError}
|
||||||
|
<div class="error">{inviteError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="ghost" onclick={() => (inviteOpen = false)} disabled={invitePending}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="primary" disabled={!canInvite}>
|
||||||
|
{invitePending ? 'Creating…' : 'Create user'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Edit modal -->
|
||||||
|
{#if editTarget}
|
||||||
|
{@const target = editTarget}
|
||||||
|
<div
|
||||||
|
class="modal-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget && !editPending) editTarget = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form class="modal" onsubmit={submitEdit}>
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>Edit {target.username}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="x"
|
||||||
|
aria-label="Close"
|
||||||
|
disabled={editPending}
|
||||||
|
onclick={() => (editTarget = null)}>✕</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<label class="field">
|
||||||
|
<span>Username</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value={editForm.username}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{#if editForm.username && !editUsernameValid}
|
||||||
|
<small class="invalid">2–32 chars, lowercase + digits + . _ - only.</small>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Email <span class="opt">(optional)</span></span>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
bind:value={editForm.email}
|
||||||
|
/>
|
||||||
|
{#if !editEmailValid}
|
||||||
|
<small class="invalid">Doesn't look like an email address.</small>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Role</span>
|
||||||
|
<select bind:value={editForm.instance_role}>
|
||||||
|
{#each editRoleOptions as r (r)}
|
||||||
|
<option value={r}>{r}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<small>
|
||||||
|
{#if target.instance_role === 'owner' && !isOwner}
|
||||||
|
Only owners can change another owner's role.
|
||||||
|
{:else if !isOwner}
|
||||||
|
Admins can grant admin or member; only owners can grant owner.
|
||||||
|
{:else}
|
||||||
|
The last active owner can't be demoted — the request will 422 if that's the case.
|
||||||
|
{/if}
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
<label class="toggle">
|
||||||
|
<input type="checkbox" bind:checked={editForm.is_active} />
|
||||||
|
<span>Active</span>
|
||||||
|
<small>Unchecking signs the user out and expires all their API keys immediately.</small>
|
||||||
|
</label>
|
||||||
|
{#if editError}
|
||||||
|
<div class="error">{editError}</div>
|
||||||
|
{/if}
|
||||||
|
<div class="modal-actions split">
|
||||||
|
<button type="button" class="ghost" onclick={resetPassword} disabled={editPending}>
|
||||||
|
Reset password
|
||||||
|
</button>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ghost"
|
||||||
|
onclick={() => (editTarget = null)}
|
||||||
|
disabled={editPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="primary" disabled={!canSubmitEdit}>
|
||||||
|
{editPending ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Password reveal (post-invite or post-reset) -->
|
||||||
|
{#if revealPassword}
|
||||||
|
<div class="modal-backdrop" role="presentation">
|
||||||
|
<div class="modal reveal-modal">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>
|
||||||
|
{revealKind === 'invite' ? 'User created' : 'Password reset'} — {revealForUsername}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p class="banner banner-warn">
|
||||||
|
Save this password now — it will never be shown again. PiCloud cannot send email yet,
|
||||||
|
so copy it and share through your own channel.
|
||||||
|
</p>
|
||||||
|
<div class="token-row">
|
||||||
|
<code class="token">{revealPassword}</code>
|
||||||
|
<button type="button" class="ghost" onclick={copyPassword}>
|
||||||
|
{copyState === 'copied' ? 'Copied ✓' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label class="ack">
|
||||||
|
<input type="checkbox" bind:checked={revealAck} />
|
||||||
|
<span>I've shared this with the user.</span>
|
||||||
|
</label>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="primary" disabled={!revealAck} onclick={dismissReveal}>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Delete confirmation -->
|
||||||
|
{#if deleteTarget}
|
||||||
|
{@const dt = deleteTarget}
|
||||||
|
<ConfirmModal
|
||||||
|
title="Delete user?"
|
||||||
|
variant="danger"
|
||||||
|
confirmLabel="Delete user"
|
||||||
|
confirmPhrase={dt.username}
|
||||||
|
confirmPhrasePrompt="Type the username to confirm:"
|
||||||
|
busy={deletePending}
|
||||||
|
busyLabel="Deleting…"
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => (deleteTarget = null)}
|
||||||
|
>
|
||||||
|
{#if me && me.id === dt.id}
|
||||||
|
<p>
|
||||||
|
You're about to delete <strong>your own</strong> account. You'll be signed out
|
||||||
|
immediately and won't be able to sign back in.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p>
|
||||||
|
This permanently removes <strong>{dt.username}</strong>, all their sessions, and all
|
||||||
|
their API keys. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
<p class="muted">
|
||||||
|
If they're the only remaining owner or active admin the server will reject the request
|
||||||
|
with a 422 — promote/activate someone else first.
|
||||||
|
</p>
|
||||||
|
</ConfirmModal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.head h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin: 0;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.head-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.search {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-width: 16rem;
|
||||||
|
}
|
||||||
|
.search:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
padding: 0.55rem 0.85rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.banner-error {
|
||||||
|
background: #450a0a;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
.banner-info {
|
||||||
|
background: #0c2a36;
|
||||||
|
border: 1px solid #155e75;
|
||||||
|
color: #a5f3fc;
|
||||||
|
}
|
||||||
|
.banner-warn {
|
||||||
|
background: #2a1d04;
|
||||||
|
border: 1px solid #ca8a04;
|
||||||
|
color: #fde68a;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2.5rem 0;
|
||||||
|
border: 1px dashed #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: #0b1220;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.3fr 0.7fr 1.5fr 0.9fr 0.8fr 0.9fr 1.6fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.head-row {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
.empty-row {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: #64748b;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
.name-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.you-tag {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.email-cell {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.status-active {
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
.status-inactive {
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.actions-col {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.25rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.row-action {
|
||||||
|
background: transparent;
|
||||||
|
color: #cbd5e1;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.row-action:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.danger-link {
|
||||||
|
color: #fca5a5;
|
||||||
|
border-color: #7f1d1d;
|
||||||
|
}
|
||||||
|
.danger-link:hover {
|
||||||
|
background: #450a0a;
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: #38bdf8;
|
||||||
|
color: #0b1220;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 0.9rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
button.primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
button.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: #94a3b8;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
button.ghost:hover {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #450a0a;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.retry {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #b91c1c;
|
||||||
|
color: #fecaca;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(2, 6, 23, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 28rem;
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
.reveal-modal {
|
||||||
|
border-color: #ca8a04;
|
||||||
|
}
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.modal h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.x {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.x:hover {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.modal-intro {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.field legend {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
.field input[type='text'],
|
||||||
|
.field input[type='email'],
|
||||||
|
.field select {
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #38bdf8;
|
||||||
|
}
|
||||||
|
.field small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.field small.invalid {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
.field small code {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #cbd5e1;
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
border-radius: 0.2rem;
|
||||||
|
}
|
||||||
|
.opt {
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
.toggle :global(input[type='checkbox']) {
|
||||||
|
margin-right: 0.4rem;
|
||||||
|
}
|
||||||
|
.toggle small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
margin-left: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.token {
|
||||||
|
flex: 1;
|
||||||
|
background: #020617;
|
||||||
|
border: 1px solid #1e293b;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ack {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.modal-actions.split {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user