Files
plainleaf/client/spaces_ui/components/UsersView.tsx
T
2026-07-23 14:52:48 +02:00

372 lines
11 KiB
TypeScript

import { useEffect, useState } from "preact/hooks";
import {
Alert,
Badge,
Button,
Checkbox,
Input,
} from "@silverbulletmd/silverbullet/ui";
import {
createToken,
createUser,
deleteToken,
deleteUser,
formatApiError,
getUser,
listUsers,
setUserAdmin,
setUserPassword,
} from "../api.ts";
import { useNavigate } from "../navigation.ts";
import { spacesUrl } from "../routes.ts";
import type { UserInfo } from "../types.ts";
function useUserList(onUnauthorized: () => void) {
const [users, setUsers] = useState<Record<string, UserInfo>>({});
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
listUsers()
.then((users) => {
setUsers(users);
setLoaded(true);
})
.catch((error: any) => {
if (error.unauthorized) onUnauthorized();
else setError(formatApiError(error));
setLoaded(true);
});
}, []);
return { users, loaded, error };
}
export function UserList({
currentUsername,
onUnauthorized,
}: {
currentUsername: string;
onUnauthorized: () => void;
}) {
const { users, loaded, error } = useUserList(onUnauthorized);
return (
<div>
{/* No heading: this screen is only ever reached from the tab bar, which
already names it. See SpaceList for the non-admin case. */}
{error && <Alert variant="error">{error}</Alert>}
{!loaded && <p>Loading…</p>}
{loaded && Object.keys(users).length === 0 && <p>No users yet.</p>}
{loaded && Object.keys(users).length > 0 && (
<table class="sb-user-table">
<thead>
<tr>
<th>Name</th>
<th>Role</th>
{/* Actions column; the header stays empty. */}
<th></th>
</tr>
</thead>
<tbody>
{Object.entries(users)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([name, user]) => {
const href = spacesUrl(`/users/${encodeURIComponent(name)}`);
return (
<tr key={name}>
<td>
<a class="sb-user-link" href={href}>
{name}
</a>{" "}
{name === currentUsername && <Badge>you</Badge>}
</td>
<td>{user.admin ? "admin" : "user"}</td>
<td>
{/* Same destination as the name — an explicit control
for anyone who doesn't read the name as clickable,
mirroring the spaces list. */}
<a class="sb-button sb-user-edit" href={href}>
Edit
</a>
</td>
</tr>
);
})}
</tbody>
</table>
)}
{loaded && (
<div class="row">
<a class="sb-button sb-button-primary" href={spacesUrl("/users/new")}>
Create user
</a>
</div>
)}
</div>
);
}
export function NewUser({ onUnauthorized }: { onUnauthorized: () => void }) {
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [admin, setAdmin] = useState(false);
const [error, setError] = useState("");
return (
<form
onSubmit={(event) => {
event.preventDefault();
createUser(username, password, admin)
.then(() =>
navigate(
spacesUrl(`/users/${encodeURIComponent(username.trim())}`),
),
)
.catch((error: any) => {
if (error.unauthorized) onUnauthorized();
else setError(formatApiError(error));
});
}}
>
<h1>Create user</h1>
{error && <Alert variant="error">{error}</Alert>}
<label for="new-user-username">Username</label>
<Input
id="new-user-username"
value={username}
onInput={(event) => setUsername(event.currentTarget.value)}
/>
<label for="new-user-password">Password</label>
<Input
id="new-user-password"
type="password"
value={password}
onInput={(event) => setPassword(event.currentTarget.value)}
/>
<label>
<Checkbox
checked={admin}
onChange={(event) => setAdmin(event.currentTarget.checked)}
/>{" "}
Admin
</label>
<div class="row">
<Button type="submit" variant="primary">
Create user
</Button>
<a class="sb-button" href={spacesUrl("/users")}>
Cancel
</a>
</div>
</form>
);
}
export function UserDetail({
username,
currentUsername,
onUnauthorized,
}: {
username: string;
currentUsername: string;
onUnauthorized: () => void;
}) {
const navigate = useNavigate();
const [user, setUser] = useState<UserInfo | undefined>();
const [loaded, setLoaded] = useState(false);
const [notFound, setNotFound] = useState(false);
const [error, setError] = useState("");
const [password, setPassword] = useState("");
const [tokenName, setTokenName] = useState("");
const [shownToken, setShownToken] = useState<string | undefined>();
const isSelf = username === currentUsername;
async function reload() {
try {
setUser(await getUser(username));
setError("");
} catch (error: any) {
if (error.unauthorized) onUnauthorized();
else if (error.notFound) setNotFound(true);
else setError(formatApiError(error));
} finally {
setLoaded(true);
}
}
useEffect(() => {
void reload();
}, [username]);
async function run(action: () => Promise<void>) {
try {
await action();
setError("");
} catch (error: any) {
if (error.unauthorized) onUnauthorized();
else setError(formatApiError(error));
}
}
if (!loaded) return <p>Loading…</p>;
if (notFound) {
return (
<div>
<h1>User not found</h1>
<p>
<a href={spacesUrl("/users")}>Return to users</a>
</p>
</div>
);
}
if (!user) return <Alert variant="error">{error || "User not found"}</Alert>;
const tokenNames = Object.keys(user.tokens);
return (
<div>
<h1>
{username} {isSelf && <Badge>you</Badge>}
</h1>
{error && <Alert variant="error">{error}</Alert>}
<section>
<h2>Role</h2>
<label>
<Checkbox
checked={user.admin}
onChange={(event) => {
const admin = event.currentTarget.checked;
if (
isSelf &&
!admin &&
!confirm(
`Remove admin rights from your own account "${username}"? Your session will lose admin access immediately.`,
)
) {
event.currentTarget.checked = true;
return;
}
void run(async () => {
await setUserAdmin(username, admin);
if (isSelf && !admin) location.assign("/");
else await reload();
});
}}
/>{" "}
Administrator
</label>
</section>
<section>
<h2>Password</h2>
<div class="row">
<Input
type="password"
aria-label="New password"
placeholder="New password"
value={password}
onInput={(event) => setPassword(event.currentTarget.value)}
/>
<Button
variant="primary"
onClick={() =>
void run(async () => {
await setUserPassword(username, password);
setPassword("");
if (isSelf) location.assign(loginUrlForUser(username));
})
}
>
Set password
</Button>
</div>
</section>
<section>
<h2>API tokens</h2>
{tokenNames.length === 0 && <p>No tokens.</p>}
{tokenNames.length > 0 && (
<ul class="sb-token-list">
{tokenNames.map((name) => (
<li key={name}>
<strong>{name}</strong>
<span>
created{" "}
{new Date(user.tokens[name].createdAt).toLocaleString()}
</span>
<Button
onClick={() => {
if (!confirm(`Revoke token "${name}" for "${username}"?`)) {
return;
}
void run(async () => {
await deleteToken(username, name);
await reload();
});
}}
>
Revoke
</Button>
</li>
))}
</ul>
)}
<div class="row">
<Input
aria-label="Token name"
placeholder="Token name"
value={tokenName}
onInput={(event) => setTokenName(event.currentTarget.value)}
/>
<Button
variant="primary"
onClick={() => {
const name = tokenName.trim();
if (!name) return;
void run(async () => {
setShownToken(await createToken(username, name));
setTokenName("");
await reload();
});
}}
>
Create token
</Button>
</div>
{shownToken && (
<div class="sb-token-reveal">
<Alert variant="warning">
This token is shown only once — copy it now.
</Alert>
<Input
readOnly
value={shownToken}
onClick={(event) => event.currentTarget.select()}
/>
<Button onClick={() => setShownToken(undefined)}>Dismiss</Button>
</div>
)}
</section>
<div class="sb-danger-zone">
<Button
variant="danger"
onClick={() => {
const message = isSelf
? `Delete your own account "${username}"? You will be logged out immediately.`
: `Delete user "${username}"?`;
if (!confirm(message)) return;
void run(async () => {
await deleteUser(username);
// Deleting your own account ends the session, so that one has
// to be a real navigation out of the app.
if (isSelf) location.assign("/");
else navigate(spacesUrl("/users"));
});
}}
>
Delete user
</Button>
</div>
</div>
);
}
function loginUrlForUser(username: string): string {
const next = spacesUrl(`/users/${encodeURIComponent(username)}`);
return `${spacesUrl("/login")}?next=${encodeURIComponent(next)}`;
}