799 lines
28 KiB
Svelte
799 lines
28 KiB
Svelte
<script lang="ts">
|
||
import Select from '$ui/components/primitives/Select.svelte';
|
||
import Input from '$ui/components/primitives/Input.svelte';
|
||
import Field from '$ui/components/primitives/Field.svelte';
|
||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||
import { onMount } from 'svelte';
|
||
import { page } from '$app/stores';
|
||
import { invalidateAll } from '$app/navigation';
|
||
import type { AuthUser, UserRole } from '$lib/auth.js';
|
||
import { confirmAction } from '$ui/confirm.js';
|
||
import Button from '$ui/components/primitives/Button.svelte';
|
||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||
let users = $state<AuthUser[]>([]);
|
||
let loading = $state(true);
|
||
let busy = $state(false);
|
||
let error = $state('');
|
||
let notice = $state('');
|
||
let formError = $state('');
|
||
let createOpen = $state(false);
|
||
let editOpen = $state(false);
|
||
let editing = $state<AuthUser | null>(null);
|
||
let username = $state('');
|
||
let displayName = $state('');
|
||
let password = $state('');
|
||
let currentPassword = $state('');
|
||
let role = $state<UserRole>('viewer');
|
||
let enabled = $state(true);
|
||
let showPassword = $state(false);
|
||
interface AuditEvent {
|
||
id: number;
|
||
actorId: string | null;
|
||
actor: string | null;
|
||
action: string;
|
||
targetId: string | null;
|
||
details: string;
|
||
createdAt: number;
|
||
}
|
||
let events = $state<AuditEvent[]>([]);
|
||
let auditLoading = $state(true);
|
||
let auditError = $state('');
|
||
const roles: { value: UserRole; label: string; description: string }[] = [
|
||
{
|
||
value: 'viewer',
|
||
label: 'Viewer',
|
||
description: 'Read fleet status and operation summaries.'
|
||
},
|
||
{
|
||
value: 'super_viewer',
|
||
label: 'Super Viewer',
|
||
description: 'Read all fleet and administrator pages without making changes.'
|
||
},
|
||
{
|
||
value: 'operator',
|
||
label: 'Operator',
|
||
description: 'Operate servers, campaigns, scripts, and configs.'
|
||
},
|
||
{
|
||
value: 'moderator',
|
||
label: 'Moderator',
|
||
description: 'Moderate players with server or game restrictions lasting up to 7 days.'
|
||
},
|
||
{
|
||
value: 'admin',
|
||
label: 'Administrator',
|
||
description: 'Manage users, access, settings, hosts, and the fleet.'
|
||
}
|
||
];
|
||
const roleDescription = $derived(roles.find((item) => item.value === role)?.description);
|
||
const administrator = $derived($page.data.user?.role === 'admin');
|
||
const rememberedSession = $derived($page.data.rememberedSession);
|
||
const canViewUsers = $derived(administrator || $page.data.user?.role === 'super_viewer');
|
||
$effect(() => {
|
||
if (!createOpen && !editOpen) currentPassword = '';
|
||
});
|
||
|
||
async function load() {
|
||
loading = true;
|
||
try {
|
||
const response = await fetch('/api/users');
|
||
const body = await response.json();
|
||
if (!response.ok) throw new Error(body.error ?? 'Could not load users.');
|
||
users = body.users;
|
||
} catch (cause) {
|
||
error = (cause as Error).message;
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
}
|
||
async function loadAudit() {
|
||
auditLoading = true;
|
||
auditError = '';
|
||
try {
|
||
const response = await fetch('/api/users/audit');
|
||
const body = await response.json();
|
||
if (!response.ok) throw new Error(body.error ?? 'Could not load account activity.');
|
||
events = body.events;
|
||
} catch (cause) {
|
||
auditError = (cause as Error).message;
|
||
} finally {
|
||
auditLoading = false;
|
||
}
|
||
}
|
||
const actionLabels: Record<string, string> = {
|
||
setup: 'Administrator created',
|
||
login: 'Signed in',
|
||
'login.failed': 'Sign-in failed',
|
||
logout: 'Signed out',
|
||
'sessions.revoked': 'Sessions ended',
|
||
'user.created': 'User created',
|
||
'user.updated': 'Access updated',
|
||
'password.changed': 'Password changed'
|
||
};
|
||
function auditDetails(value: string): string {
|
||
try {
|
||
const parsed = JSON.parse(value);
|
||
return Object.keys(parsed).length ? JSON.stringify(parsed, null, 2) : '';
|
||
} catch {
|
||
return value;
|
||
}
|
||
}
|
||
onMount(() => {
|
||
if (canViewUsers) void Promise.all([load(), loadAudit()]);
|
||
else {
|
||
loading = false;
|
||
auditLoading = false;
|
||
}
|
||
});
|
||
function openCreate() {
|
||
if (!administrator) return;
|
||
currentPassword = '';
|
||
username = '';
|
||
displayName = '';
|
||
password = '';
|
||
role = 'viewer';
|
||
enabled = true;
|
||
showPassword = false;
|
||
formError = '';
|
||
editing = null;
|
||
createOpen = true;
|
||
}
|
||
function openEdit(user: AuthUser) {
|
||
if (!administrator) return;
|
||
currentPassword = '';
|
||
editing = user;
|
||
username = user.username;
|
||
displayName = user.displayName;
|
||
password = '';
|
||
role = user.role;
|
||
enabled = !user.disabled;
|
||
showPassword = false;
|
||
formError = '';
|
||
editOpen = true;
|
||
}
|
||
async function create(event: SubmitEvent) {
|
||
event.preventDefault();
|
||
if (!administrator || busy) return;
|
||
busy = true;
|
||
formError = '';
|
||
error = '';
|
||
notice = '';
|
||
try {
|
||
const response = await fetch('/api/users', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username, displayName, password, role, currentPassword })
|
||
});
|
||
const body = await response.json();
|
||
if (!response.ok) throw new Error(body.error ?? 'Could not create this user.');
|
||
password = '';
|
||
createOpen = false;
|
||
notice = `${body.user.displayName || body.user.username} was added. They must change the temporary password at sign-in.`;
|
||
await Promise.all([load(), loadAudit()]);
|
||
} catch (cause) {
|
||
formError = (cause as Error).message;
|
||
} finally {
|
||
currentPassword = '';
|
||
busy = false;
|
||
}
|
||
}
|
||
async function save(event: SubmitEvent) {
|
||
event.preventDefault();
|
||
if (!administrator || busy || !editing) return;
|
||
busy = true;
|
||
formError = '';
|
||
error = '';
|
||
notice = '';
|
||
const ownAccessChanged =
|
||
editing.id === $page.data.user?.id &&
|
||
(role !== editing.role || enabled === editing.disabled || !!password);
|
||
try {
|
||
const response = await fetch(`/api/users/${encodeURIComponent(editing.id)}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
displayName,
|
||
role,
|
||
disabled: !enabled,
|
||
currentPassword,
|
||
...(password ? { password } : {})
|
||
})
|
||
});
|
||
const body = await response.json();
|
||
if (!response.ok) throw new Error(body.error ?? 'Could not save user changes.');
|
||
password = '';
|
||
editOpen = false;
|
||
if (ownAccessChanged) {
|
||
window.location.assign('/login');
|
||
return;
|
||
}
|
||
notice = `Changes saved for ${body.user.displayName || body.user.username}.`;
|
||
await Promise.all([load(), loadAudit()]);
|
||
await invalidateAll();
|
||
} catch (cause) {
|
||
formError = (cause as Error).message;
|
||
} finally {
|
||
currentPassword = '';
|
||
busy = false;
|
||
}
|
||
}
|
||
async function revoke(user: AuthUser) {
|
||
if (!administrator || busy) return;
|
||
const own = user.id === $page.data.user?.id;
|
||
if (
|
||
!(await confirmAction({
|
||
title: `Sign out ${user.displayName || user.username}?`,
|
||
message: own
|
||
? 'This will end all of your sessions, including this one. You can sign in again with your password.'
|
||
: 'This ends all active sessions for this user. Their account and password remain available for a new sign-in.',
|
||
confirmLabel: 'Sign out sessions',
|
||
danger: true
|
||
}))
|
||
)
|
||
return;
|
||
busy = true;
|
||
error = '';
|
||
notice = '';
|
||
try {
|
||
const response = await fetch(
|
||
`/api/users/${encodeURIComponent(user.id)}/revoke-sessions`,
|
||
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }
|
||
);
|
||
const body = await response.json();
|
||
if (!response.ok) throw new Error(body.error ?? 'Could not end these sessions.');
|
||
if (own) {
|
||
window.location.assign('/login');
|
||
return;
|
||
}
|
||
notice = `All sessions ended for ${user.displayName || user.username}.`;
|
||
await loadAudit();
|
||
} catch (cause) {
|
||
error = (cause as Error).message;
|
||
} finally {
|
||
busy = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<svelte:head><title>Users · Electric Cherry</title></svelte:head>
|
||
<section class="page">
|
||
<SectionHeader
|
||
title="Users"
|
||
subtitle={administrator
|
||
? 'Create accounts and manage who can operate your fleet.'
|
||
: 'View fleet accounts, access roles, and account activity.'}
|
||
>
|
||
{#snippet actions()}{#if administrator}<Button
|
||
variant="primary"
|
||
disabled={busy}
|
||
onclick={openCreate}>Add user</Button
|
||
>{/if}{/snippet}
|
||
</SectionHeader>
|
||
{#if !canViewUsers}<p class="error" role="alert">You do not have access to view users.</p>
|
||
<a href="/account">Go to your account</a>
|
||
{:else}
|
||
{#if error}<p class="error" role="alert">{error}</p>{/if}{#if notice}<p
|
||
class="notice"
|
||
role="status"
|
||
>
|
||
{notice}
|
||
</p>{/if}
|
||
<div class="roles" aria-label="Access roles">
|
||
{#each roles as item}<div>
|
||
<h2>{item.label}</h2>
|
||
<p>{item.description}</p>
|
||
</div>{/each}
|
||
</div>
|
||
{#if loading}<p class="muted" role="status">Loading users…</p>
|
||
{:else if !users.length}<section class="panel">
|
||
<p>No users could be loaded.</p>
|
||
<Button onclick={load}>Try again</Button>
|
||
</section>
|
||
{:else}
|
||
<div class="user-list" aria-label="User accounts">
|
||
{#each users as user (user.id)}<article class="user-row">
|
||
<div class="user-name">
|
||
<h2>
|
||
{user.displayName}
|
||
{#if user.id === $page.data.user?.id}<span class="you">You</span
|
||
>{/if}
|
||
</h2>
|
||
<p>@{user.username}</p>
|
||
</div>
|
||
<div class="access">
|
||
<span class="role"
|
||
>{roles.find((item) => item.value === user.role)?.label ??
|
||
user.role}</span
|
||
><span class:disabled={user.disabled}
|
||
>{user.disabled ? 'Disabled' : 'Enabled'}</span
|
||
>{#if user.mustChangePassword && !user.disabled}<small
|
||
>Password change required</small
|
||
>{/if}
|
||
</div>
|
||
<div class="last-login">
|
||
<span>Last sign-in</span>
|
||
<p>
|
||
<RelativeTime
|
||
value={user.lastLoginAt}
|
||
missing={user.id === $page.data.user?.id
|
||
? 'Signed in · time not recorded'
|
||
: 'Not recorded'}
|
||
/>
|
||
</p>
|
||
</div>
|
||
{#if administrator}<div class="actions">
|
||
<Button size="sm" disabled={busy} onclick={() => openEdit(user)}
|
||
>Edit access</Button
|
||
><Button
|
||
size="sm"
|
||
variant="ghost"
|
||
disabled={busy}
|
||
onclick={() => revoke(user)}>Sign out sessions</Button
|
||
>
|
||
</div>{/if}
|
||
</article>{/each}
|
||
</div>
|
||
{/if}
|
||
<section class="panel audit" aria-labelledby="audit-title">
|
||
<div class="audit-heading">
|
||
<div>
|
||
<h2 id="audit-title">Account activity</h2>
|
||
<p class="muted">The latest 200 sign-in and account events.</p>
|
||
</div>
|
||
<Button size="sm" variant="ghost" disabled={auditLoading} onclick={loadAudit}
|
||
>Refresh activity</Button
|
||
>
|
||
</div>
|
||
{#if auditError}<p class="error" role="alert">{auditError}</p>{/if}
|
||
{#if auditLoading && !events.length}<p class="muted" role="status">
|
||
Loading account activity…
|
||
</p>
|
||
{:else if !events.length}<p class="muted">No account events recorded.</p>
|
||
{:else}<div class="table-wrap">
|
||
<table>
|
||
<thead
|
||
><tr
|
||
><th>Time</th><th>Event</th><th>By</th><th>Account</th><th
|
||
>Details</th
|
||
></tr
|
||
></thead
|
||
><tbody>
|
||
{#each events as event (event.id)}{@const details = auditDetails(
|
||
event.details
|
||
)}<tr
|
||
><td class="event-time"
|
||
><RelativeTime value={event.createdAt} /></td
|
||
><td>{actionLabels[event.action] ?? event.action}</td><td
|
||
>{event.actor ? `@${event.actor}` : 'Unauthenticated'}</td
|
||
><td
|
||
>{event.targetId
|
||
? (users.find((user) => user.id === event.targetId)
|
||
?.username ?? event.targetId)
|
||
: '—'}</td
|
||
><td
|
||
>{#if details}<details>
|
||
<summary>View details</summary>
|
||
<pre>{details}</pre>
|
||
</details>{:else}—{/if}</td
|
||
></tr
|
||
>{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>{/if}
|
||
</section>
|
||
{/if}
|
||
</section>
|
||
|
||
{#snippet accountPassword(id: string)}
|
||
{#if rememberedSession}
|
||
<Field forId={id} label="Your current password">
|
||
<Input
|
||
{id}
|
||
name="currentPassword"
|
||
type="password"
|
||
autocomplete="current-password"
|
||
required
|
||
maxlength={128}
|
||
bind:value={currentPassword}
|
||
aria-describedby={`${id}-help`}
|
||
/>
|
||
</Field>
|
||
<p class="hint" id={`${id}-help`}>
|
||
Confirm your password to make account changes on this remembered device.
|
||
</p>
|
||
{/if}
|
||
{/snippet}
|
||
|
||
{#if administrator}<Dialog
|
||
bind:open={createOpen}
|
||
title="Add user"
|
||
description="Share the temporary password privately. The user must choose a new password when they first sign in."
|
||
{busy}
|
||
>
|
||
<form onsubmit={create} aria-busy={busy}>
|
||
<fieldset disabled={busy}>
|
||
{#if formError}<p class="error" role="alert">{formError}</p>{/if}
|
||
<Field forId="new-username" label="Username">
|
||
<Input
|
||
id="new-username"
|
||
name="username"
|
||
required
|
||
minlength={3}
|
||
maxlength={64}
|
||
pattern={'[a-zA-Z0-9][a-zA-Z0-9_.\\-]{2,63}'}
|
||
autocomplete="off"
|
||
autocapitalize="none"
|
||
spellcheck="false"
|
||
bind:value={username}
|
||
aria-describedby="new-username-help"
|
||
/></Field
|
||
>
|
||
<p class="hint" id="new-username-help">
|
||
3–64 characters: letters, numbers, periods, underscores, and hyphens. Start with
|
||
a letter or number.
|
||
</p>
|
||
<Field forId="new-name" label="Display name">
|
||
<Input
|
||
id="new-name"
|
||
name="displayName"
|
||
required
|
||
maxlength={100}
|
||
autocomplete="off"
|
||
bind:value={displayName}
|
||
/></Field
|
||
>
|
||
<Field forId="new-role" label="Role">
|
||
<Select
|
||
id="new-role"
|
||
name="role"
|
||
bind:value={role}
|
||
aria-describedby="new-role-help"
|
||
>{#each roles as item}<option value={item.value}>{item.label}</option
|
||
>{/each}</Select
|
||
></Field
|
||
>
|
||
<p class="hint" id="new-role-help">{roleDescription}</p>
|
||
<Field forId="temporary-password" label="Temporary password">
|
||
<Input
|
||
id="temporary-password"
|
||
name="password"
|
||
type={showPassword ? 'text' : 'password'}
|
||
required
|
||
minlength={8}
|
||
maxlength={128}
|
||
autocomplete="new-password"
|
||
bind:value={password}
|
||
aria-describedby="temporary-help"
|
||
/></Field
|
||
>
|
||
<p class="hint" id="temporary-help">
|
||
8–128 characters. Save it before adding the user; it will not be displayed
|
||
again.
|
||
</p>
|
||
<label class="checkbox"
|
||
><input type="checkbox" bind:checked={showPassword} />Show temporary password</label
|
||
>
|
||
{@render accountPassword('create-current-password')}
|
||
<div class="form-actions">
|
||
<Button
|
||
disabled={busy}
|
||
variant="ghost"
|
||
onclick={() => {
|
||
createOpen = false;
|
||
password = '';
|
||
}}>Cancel</Button
|
||
><Button type="submit" variant="primary" loading={busy} disabled={busy}
|
||
>Add user</Button
|
||
>
|
||
</div>
|
||
</fieldset>
|
||
</form>
|
||
</Dialog>
|
||
|
||
<Dialog
|
||
bind:open={editOpen}
|
||
title={`Edit ${editing?.username ?? 'user'}`}
|
||
description="Update access or set a new temporary password."
|
||
{busy}
|
||
>
|
||
<form onsubmit={save} aria-busy={busy}>
|
||
<fieldset disabled={busy}>
|
||
{#if formError}<p class="error" role="alert">{formError}</p>{/if}
|
||
<Field forId="edit-name" label="Display name">
|
||
<Input
|
||
id="edit-name"
|
||
name="displayName"
|
||
required
|
||
maxlength={100}
|
||
autocomplete="off"
|
||
bind:value={displayName}
|
||
/></Field
|
||
>
|
||
<Field forId="edit-role" label="Role">
|
||
<Select
|
||
id="edit-role"
|
||
name="role"
|
||
bind:value={role}
|
||
aria-describedby="edit-role-help"
|
||
>{#each roles as item}<option value={item.value}>{item.label}</option
|
||
>{/each}</Select
|
||
></Field
|
||
>
|
||
<p class="hint" id="edit-role-help">{roleDescription}</p>
|
||
<label class="checkbox"
|
||
><input type="checkbox" bind:checked={enabled} />Account enabled</label
|
||
>
|
||
<p class="hint">
|
||
Disabled users cannot sign in. Running game servers continue operating.
|
||
</p>
|
||
{#if editing?.id === $page.data.user?.id}<p class="hint">
|
||
Use <a href="/account">My account</a> to change your own password. Changes to
|
||
your access can sign you out.
|
||
</p>
|
||
{:else}<Field forId="reset-password"
|
||
>{#snippet label()}New temporary password <span class="optional"
|
||
>(optional)</span
|
||
>{/snippet}
|
||
<Input
|
||
id="reset-password"
|
||
name="password"
|
||
type={showPassword ? 'text' : 'password'}
|
||
minlength={8}
|
||
maxlength={128}
|
||
autocomplete="new-password"
|
||
bind:value={password}
|
||
aria-describedby="reset-help"
|
||
/></Field
|
||
>
|
||
<p class="hint" id="reset-help">
|
||
Leave blank to keep the current password. A reset requires the user to
|
||
choose a new password at their next sign-in.
|
||
</p>
|
||
<label class="checkbox"
|
||
><input type="checkbox" bind:checked={showPassword} />Show temporary
|
||
password</label
|
||
>{/if}
|
||
{@render accountPassword('edit-current-password')}
|
||
<div class="form-actions">
|
||
<Button
|
||
disabled={busy}
|
||
variant="ghost"
|
||
onclick={() => {
|
||
editOpen = false;
|
||
password = '';
|
||
}}>Cancel</Button
|
||
><Button type="submit" variant="primary" loading={busy} disabled={busy}
|
||
>Save changes</Button
|
||
>
|
||
</div>
|
||
</fieldset>
|
||
</form>
|
||
</Dialog>{/if}
|
||
|
||
<style>
|
||
.page {
|
||
display: grid;
|
||
gap: 24px;
|
||
}
|
||
.panel,
|
||
.roles,
|
||
.user-list {
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: var(--radius-md);
|
||
background: var(--surface);
|
||
}
|
||
.roles {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 24px;
|
||
padding: 24px;
|
||
}
|
||
h2 {
|
||
margin: 0;
|
||
font-size: var(--text-md);
|
||
}
|
||
.roles p,
|
||
.muted {
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-sm);
|
||
line-height: 1.6;
|
||
}
|
||
.roles p {
|
||
margin: 8px 0 0;
|
||
}
|
||
.panel {
|
||
padding: 24px;
|
||
}
|
||
.user-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(180px, 1.3fr) minmax(120px, 0.8fr) minmax(140px, 1fr) auto;
|
||
gap: 24px;
|
||
align-items: center;
|
||
padding: 24px;
|
||
}
|
||
.user-row + .user-row {
|
||
border-top: 1px solid var(--border-subtle);
|
||
}
|
||
.user-name p {
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-sm);
|
||
margin: 6px 0 0;
|
||
}
|
||
.you {
|
||
padding: 3px 7px;
|
||
margin-left: 6px;
|
||
background: var(--surface-raised);
|
||
border-radius: var(--radius-sm);
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-xs);
|
||
font-weight: normal;
|
||
}
|
||
.access {
|
||
display: grid;
|
||
gap: 5px;
|
||
font-size: var(--text-sm);
|
||
}
|
||
.access .role {
|
||
color: var(--text-primary);
|
||
text-transform: capitalize;
|
||
}
|
||
.access span:not(.role),
|
||
.access small {
|
||
color: var(--text-secondary);
|
||
font-size: var(--text-xs);
|
||
}
|
||
.access span.disabled {
|
||
color: var(--state-danger);
|
||
}
|
||
.last-login > span {
|
||
font-size: var(--text-xs);
|
||
color: var(--text-secondary);
|
||
}
|
||
.last-login p {
|
||
font-size: var(--text-sm);
|
||
margin: 5px 0 0;
|
||
}
|
||
.actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
justify-content: end;
|
||
}
|
||
.audit {
|
||
min-width: 0;
|
||
}
|
||
.audit-heading {
|
||
display: flex;
|
||
gap: 16px;
|
||
align-items: start;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
}
|
||
.audit-heading p {
|
||
margin: 6px 0 20px;
|
||
}
|
||
.table-wrap {
|
||
overflow-x: auto;
|
||
}
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
text-align: left;
|
||
font-size: var(--text-sm);
|
||
}
|
||
th,
|
||
td {
|
||
padding: 12px;
|
||
border-bottom: 1px solid var(--border-subtle);
|
||
vertical-align: top;
|
||
}
|
||
th {
|
||
color: var(--text-secondary);
|
||
font-weight: 500;
|
||
}
|
||
td {
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.event-time {
|
||
min-width: 165px;
|
||
}
|
||
details {
|
||
min-width: 120px;
|
||
}
|
||
summary {
|
||
cursor: pointer;
|
||
color: var(--accent-primary-hover);
|
||
}
|
||
pre {
|
||
white-space: pre-wrap;
|
||
font-size: var(--text-xs);
|
||
max-width: 320px;
|
||
}
|
||
fieldset {
|
||
display: grid;
|
||
gap: 16px;
|
||
border: 0;
|
||
padding: 0;
|
||
margin: 0;
|
||
min-width: 0;
|
||
}
|
||
label {
|
||
display: grid;
|
||
gap: 8px;
|
||
font-size: var(--text-sm);
|
||
}
|
||
input:not([type='checkbox']) {
|
||
width: 100%;
|
||
padding: 11px;
|
||
color: var(--text-primary);
|
||
background: var(--surface-sunken);
|
||
border: 1px solid var(--border-control);
|
||
border-radius: var(--radius-md);
|
||
font: inherit;
|
||
}
|
||
.checkbox {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.checkbox input {
|
||
width: 18px;
|
||
height: 18px;
|
||
accent-color: var(--accent-primary);
|
||
}
|
||
.hint {
|
||
font-size: var(--text-xs);
|
||
color: var(--text-secondary);
|
||
line-height: 1.6;
|
||
margin: -8px 0 0;
|
||
}
|
||
.optional {
|
||
color: var(--text-secondary);
|
||
}
|
||
.form-actions {
|
||
display: flex;
|
||
gap: 12px;
|
||
justify-content: end;
|
||
margin-top: 8px;
|
||
}
|
||
.error {
|
||
color: var(--state-danger);
|
||
font-size: var(--text-sm);
|
||
line-height: 1.6;
|
||
margin: 0;
|
||
}
|
||
.notice {
|
||
color: var(--state-online);
|
||
font-size: var(--text-sm);
|
||
margin: 0;
|
||
}
|
||
@media (max-width: 1100px) {
|
||
.user-row {
|
||
grid-template-columns: minmax(140px, 1.3fr) minmax(100px, 1fr) minmax(140px, 1fr);
|
||
}
|
||
.actions {
|
||
grid-column: 1/-1;
|
||
justify-content: start;
|
||
}
|
||
}
|
||
@media (max-width: 640px) {
|
||
.roles {
|
||
grid-template-columns: 1fr;
|
||
gap: 20px;
|
||
}
|
||
.user-row {
|
||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||
gap: 20px;
|
||
padding: 20px;
|
||
}
|
||
.user-name {
|
||
grid-column: 1/-1;
|
||
}
|
||
.roles,
|
||
.panel {
|
||
padding: 20px;
|
||
}
|
||
}
|
||
</style>
|