feat(admin): Gebruikers screen (list, create, role, password, deactivate)
This commit is contained in:
@@ -6,6 +6,7 @@ import Live from './screens/Live';
|
|||||||
import Activities from './screens/Activities';
|
import Activities from './screens/Activities';
|
||||||
import Sessions from './screens/Sessions';
|
import Sessions from './screens/Sessions';
|
||||||
import Reports from './screens/Reports';
|
import Reports from './screens/Reports';
|
||||||
|
import Users from './screens/Users';
|
||||||
|
|
||||||
function AuthedShell() {
|
function AuthedShell() {
|
||||||
return (
|
return (
|
||||||
@@ -18,6 +19,7 @@ function AuthedShell() {
|
|||||||
<Route path="/handelingen" element={<Activities />} />
|
<Route path="/handelingen" element={<Activities />} />
|
||||||
<Route path="/sessies" element={<Sessions />} />
|
<Route path="/sessies" element={<Sessions />} />
|
||||||
<Route path="/rapporten" element={<Reports />} />
|
<Route path="/rapporten" element={<Reports />} />
|
||||||
|
<Route path="/gebruikers" element={<Users />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
54
apps/admin/src/api/users.ts
Normal file
54
apps/admin/src/api/users.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { AdminUser, CreateUserInput, Role } from '@solelog/shared';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
export function useUsers() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['admin', 'users'],
|
||||||
|
queryFn: () => apiFetch<AdminUser[]>('/api/admin/users'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useUsersMutation<T>(fn: (arg: T) => Promise<unknown>) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: fn,
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateUser() {
|
||||||
|
return useUsersMutation((input: CreateUserInput) =>
|
||||||
|
apiFetch<AdminUser>('/api/admin/users', { method: 'POST', body: JSON.stringify(input) }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetUserRole() {
|
||||||
|
return useUsersMutation(({ id, role }: { id: string; role: Role }) =>
|
||||||
|
apiFetch<AdminUser>(`/api/admin/users/${id}/role`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ role }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useResetUserPassword() {
|
||||||
|
return useUsersMutation(({ id, password }: { id: string; password: string }) =>
|
||||||
|
apiFetch<{ success: true }>(`/api/admin/users/${id}/password`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ password }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeactivateUser() {
|
||||||
|
return useUsersMutation((id: string) =>
|
||||||
|
apiFetch<AdminUser>(`/api/admin/users/${id}/deactivate`, { method: 'POST' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReactivateUser() {
|
||||||
|
return useUsersMutation((id: string) =>
|
||||||
|
apiFetch<AdminUser>(`/api/admin/users/${id}/reactivate`, { method: 'POST' }),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,11 +7,9 @@ const navItems = [
|
|||||||
{ to: '/handelingen', label: 'Handelingen' },
|
{ to: '/handelingen', label: 'Handelingen' },
|
||||||
{ to: '/sessies', label: 'Sessies' },
|
{ to: '/sessies', label: 'Sessies' },
|
||||||
{ to: '/rapporten', label: 'Rapporten' },
|
{ to: '/rapporten', label: 'Rapporten' },
|
||||||
|
{ to: '/gebruikers', label: 'Gebruikers' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Sections planned for the final Phase 3b cycle — shown muted/disabled.
|
|
||||||
const soonItems = ['Gebruikers'] as const;
|
|
||||||
|
|
||||||
export default function Sidebar() {
|
export default function Sidebar() {
|
||||||
const { signOut } = useAuth();
|
const { signOut } = useAuth();
|
||||||
const meQuery = useMe();
|
const meQuery = useMe();
|
||||||
@@ -32,15 +30,6 @@ export default function Sidebar() {
|
|||||||
{item.label}
|
{item.label}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="nav-soon">
|
|
||||||
<span className="nav-soon-label">Binnenkort</span>
|
|
||||||
{soonItems.map((label) => (
|
|
||||||
<span key={label} className="nav-disabled" aria-disabled="true">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="topbar">
|
<div className="topbar">
|
||||||
|
|||||||
68
apps/admin/src/components/UserForm.tsx
Normal file
68
apps/admin/src/components/UserForm.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { CreateUserInput, Role } from '@solelog/shared';
|
||||||
|
|
||||||
|
export default function UserForm({
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
pending,
|
||||||
|
error,
|
||||||
|
}: {
|
||||||
|
onSubmit: (input: CreateUserInput) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
pending: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [role, setRole] = useState<Role>('worker');
|
||||||
|
const tooShort = password.length > 0 && password.length < 8;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="user-form"
|
||||||
|
data-testid="user-form"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (password.length < 8) return;
|
||||||
|
onSubmit({ name: name.trim(), email: email.trim(), password, role });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Naam
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
E-mail
|
||||||
|
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Wachtwoord
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{tooShort && <p className="form-error">Minstens 8 tekens.</p>}
|
||||||
|
<label>
|
||||||
|
Rol
|
||||||
|
<select value={role} onChange={(e) => setRole(e.target.value as Role)}>
|
||||||
|
<option value="worker">Werker</option>
|
||||||
|
<option value="admin">Beheerder</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
<div className="user-form-actions">
|
||||||
|
<button type="submit" className="btn-primary" disabled={pending}>
|
||||||
|
Aanmaken
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onCancel}>
|
||||||
|
Annuleer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
apps/admin/src/screens/Users.test.tsx
Normal file
107
apps/admin/src/screens/Users.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import type { AdminUser } from '@solelog/shared';
|
||||||
|
import Users from './Users';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
vi.mock('../lib/api', () => ({ apiFetch: vi.fn() }));
|
||||||
|
const mockApiFetch = vi.mocked(apiFetch);
|
||||||
|
|
||||||
|
const USERS: AdminUser[] = [
|
||||||
|
{
|
||||||
|
id: 'me',
|
||||||
|
name: 'Beheerder',
|
||||||
|
email: 'admin@x',
|
||||||
|
role: 'admin',
|
||||||
|
status: 'active',
|
||||||
|
created_at: new Date('2026-06-01T00:00:00Z').toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'u2',
|
||||||
|
name: 'Jan',
|
||||||
|
email: 'jan@x',
|
||||||
|
role: 'worker',
|
||||||
|
status: 'active',
|
||||||
|
created_at: new Date('2026-06-02T00:00:00Z').toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'u3',
|
||||||
|
name: 'An',
|
||||||
|
email: 'an@x',
|
||||||
|
role: 'worker',
|
||||||
|
status: 'inactive',
|
||||||
|
created_at: new Date('2026-06-03T00:00:00Z').toISOString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function mockEndpoints() {
|
||||||
|
mockApiFetch.mockImplementation((path?: string, init?: RequestInit) => {
|
||||||
|
if (path === '/api/admin/users' && (!init || init.method === undefined))
|
||||||
|
return Promise.resolve(USERS as never);
|
||||||
|
if (path === '/api/me') return Promise.resolve({ user: USERS[0] } as never);
|
||||||
|
return Promise.resolve({} as never);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderUsers() {
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Users />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Users', () => {
|
||||||
|
beforeEach(() => mockApiFetch.mockReset());
|
||||||
|
afterEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it('renders a row per user with role and status', async () => {
|
||||||
|
mockEndpoints();
|
||||||
|
renderUsers();
|
||||||
|
expect(await screen.findByText('Jan')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('An')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('Werker').length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(screen.getByText('Inactief')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides role/deactivate actions on the signed-in admin own row', async () => {
|
||||||
|
mockEndpoints();
|
||||||
|
renderUsers();
|
||||||
|
const adminRow = (await screen.findByText('admin@x')).closest('tr') as HTMLElement;
|
||||||
|
expect(within(adminRow).getByText('jij')).toBeInTheDocument();
|
||||||
|
expect(within(adminRow).queryByRole('button', { name: 'Deactiveer' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('+ Nieuwe gebruiker posts CreateUserInput', async () => {
|
||||||
|
mockEndpoints();
|
||||||
|
renderUsers();
|
||||||
|
await screen.findByText('Jan');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: '+ Nieuwe gebruiker' }));
|
||||||
|
await userEvent.type(screen.getByLabelText('Naam'), 'Nieuw');
|
||||||
|
await userEvent.type(screen.getByLabelText('E-mail'), 'nieuw@x.nl');
|
||||||
|
await userEvent.type(screen.getByLabelText('Wachtwoord'), 'wachtwoord-123');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Aanmaken' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockApiFetch).toHaveBeenCalledWith(
|
||||||
|
'/api/admin/users',
|
||||||
|
expect.objectContaining({ method: 'POST' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Deactiveer on another user calls the deactivate endpoint', async () => {
|
||||||
|
mockEndpoints();
|
||||||
|
renderUsers();
|
||||||
|
const janRow = (await screen.findByText('Jan')).closest('tr') as HTMLElement;
|
||||||
|
await userEvent.click(within(janRow).getByRole('button', { name: 'Deactiveer' }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockApiFetch).toHaveBeenCalledWith(
|
||||||
|
'/api/admin/users/u2/deactivate',
|
||||||
|
expect.objectContaining({ method: 'POST' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
179
apps/admin/src/screens/Users.tsx
Normal file
179
apps/admin/src/screens/Users.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { AdminUser, CreateUserInput } from '@solelog/shared';
|
||||||
|
import {
|
||||||
|
useCreateUser,
|
||||||
|
useDeactivateUser,
|
||||||
|
useReactivateUser,
|
||||||
|
useResetUserPassword,
|
||||||
|
useSetUserRole,
|
||||||
|
useUsers,
|
||||||
|
} from '../api/users';
|
||||||
|
import { useMe } from '../api/me';
|
||||||
|
import UserForm from '../components/UserForm';
|
||||||
|
|
||||||
|
export default function Users() {
|
||||||
|
const usersQuery = useUsers();
|
||||||
|
const meQuery = useMe();
|
||||||
|
const myId = meQuery.data?.user.id;
|
||||||
|
|
||||||
|
const createUser = useCreateUser();
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function onCreate(input: CreateUserInput) {
|
||||||
|
setCreateError(null);
|
||||||
|
createUser.mutate(input, {
|
||||||
|
onSuccess: () => setCreating(false),
|
||||||
|
onError: () => setCreateError('E-mailadres bestaat al of ongeldig.'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usersQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="screen">
|
||||||
|
<p className="muted">Laden…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (usersQuery.isError) {
|
||||||
|
return (
|
||||||
|
<div className="screen">
|
||||||
|
<p className="muted">Kon gebruikers niet laden.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = usersQuery.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="screen">
|
||||||
|
<div className="reports-head">
|
||||||
|
<h1 className="screen-title">Gebruikers</h1>
|
||||||
|
<button type="button" className="btn-primary" onClick={() => setCreating((v) => !v)}>
|
||||||
|
+ Nieuwe gebruiker
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{creating && (
|
||||||
|
<UserForm
|
||||||
|
onSubmit={onCreate}
|
||||||
|
onCancel={() => setCreating(false)}
|
||||||
|
pending={createUser.isPending}
|
||||||
|
error={createError}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<table className="users-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Naam</th>
|
||||||
|
<th>E-mail</th>
|
||||||
|
<th>Rol</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Aangemaakt</th>
|
||||||
|
<th>Acties</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((u) => (
|
||||||
|
<UserRow key={u.id} user={u} isSelf={u.id === myId} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserRow({ user, isSelf }: { user: AdminUser; isSelf: boolean }) {
|
||||||
|
const setRole = useSetUserRole();
|
||||||
|
const deactivate = useDeactivateUser();
|
||||||
|
const reactivate = useReactivateUser();
|
||||||
|
const resetPassword = useResetUserPassword();
|
||||||
|
|
||||||
|
const [resetting, setResetting] = useState(false);
|
||||||
|
const [pw, setPw] = useState('');
|
||||||
|
const busy =
|
||||||
|
setRole.isPending || deactivate.isPending || reactivate.isPending || resetPassword.isPending;
|
||||||
|
|
||||||
|
function onReset() {
|
||||||
|
if (pw.length < 8) return;
|
||||||
|
resetPassword.mutate(
|
||||||
|
{ id: user.id, password: pw },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setResetting(false);
|
||||||
|
setPw('');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{user.name} {isSelf && <span className="user-self-badge">jij</span>}
|
||||||
|
</td>
|
||||||
|
<td>{user.email}</td>
|
||||||
|
<td>
|
||||||
|
<span className={user.role === 'admin' ? 'pill pill-admin' : 'pill pill-worker'}>
|
||||||
|
{user.role === 'admin' ? 'Beheerder' : 'Werker'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={user.status === 'active' ? 'pill pill-active' : 'pill pill-inactive'}>
|
||||||
|
{user.status === 'active' ? 'Actief' : 'Inactief'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{new Date(user.created_at).toLocaleDateString('nl-BE')}</td>
|
||||||
|
<td className="users-actions">
|
||||||
|
{!isSelf && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
setRole.mutate({ id: user.id, role: user.role === 'admin' ? 'worker' : 'admin' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{user.role === 'admin' ? 'Maak werker' : 'Maak admin'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{resetting ? (
|
||||||
|
<span className="users-reset">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
aria-label={`Nieuw wachtwoord voor ${user.name}`}
|
||||||
|
value={pw}
|
||||||
|
onChange={(e) => setPw(e.target.value)}
|
||||||
|
minLength={8}
|
||||||
|
/>
|
||||||
|
<button type="button" disabled={busy || pw.length < 8} onClick={onReset}>
|
||||||
|
Opslaan
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setResetting(false)}>
|
||||||
|
Annuleer
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button type="button" disabled={busy} onClick={() => setResetting(true)}>
|
||||||
|
Reset wachtwoord
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isSelf &&
|
||||||
|
(user.status === 'active' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-row-cancel"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => deactivate.mutate(user.id)}
|
||||||
|
>
|
||||||
|
Deactiveer
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" disabled={busy} onClick={() => reactivate.mutate(user.id)}>
|
||||||
|
Heractiveer
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -671,3 +671,74 @@ body {
|
|||||||
.reports-table th:not(:first-child) {
|
.reports-table th:not(:first-child) {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.users-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.users-table th,
|
||||||
|
.users-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--border, #e4e4e7);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.users-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.users-reset {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.pill-admin {
|
||||||
|
background: #ede9fe;
|
||||||
|
color: #6d28d9;
|
||||||
|
}
|
||||||
|
.pill-worker {
|
||||||
|
background: #e0f2fe;
|
||||||
|
color: #0369a1;
|
||||||
|
}
|
||||||
|
.pill-active {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
.pill-inactive {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
.user-self-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--muted, #71717a);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.user-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--surface, #f4f4f5);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.user-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.user-form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user