Wanted shape: the owner signs in with GitHub, everyone invited later signs in with Google. That was previously a convention, not a boundary -- may_login checked only that a live row existed, so any enabled provider could satisfy any invite. Now an invite is bound to the provider it was issued for. friend@gmail.com invited for Google cannot be redeemed over GitHub, and there is never a second, weaker door onto an existing account. The per-request re-check still passes provider=None, because there the question is only whether the account is still live. Two related fixes fell out: - upsert_user no longer refreshes `provider` from the login. It is part of the identity an admin issued and may_login now enforces it, so overwriting it on sign-in would let a successful login silently rewrite the very rule that permitted it. - a Google invite must be a full email address. A bare username would authorise something Google can never return, and the invitee would see only "not authorised" with no way to work out why. INVITE EMAILS. Adding a user now mails them, when SMTP is configured and there is an address -- for Google there always is, since the identity IS one. The response reports whether the mail actually went and why not, because "Invited" alone reads as success while the person waits for an email nobody sent. The link carries NO token, deliberately. The row already names the exact identity permitted and Google will not hand us an unverified address, so possession of the mail is not what authorises anyone: a forwarded invite grants nothing, nothing expires, and resend is always safe. A signed expiring token would only buy the ability to withdraw an unused invitation, which deleting the row already does. Resend is offered in the panel on anyone who has an address and has never signed in -- the likeliest reason being that they missed it. tests/test_invites.py: 29 assertions driving a real SMTP conversation against a sink server, plus the full provider-binding matrix. 119 tests pass across four suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
400 lines
13 KiB
TypeScript
400 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import { Ban, Check, Plus, Send, ShieldCheck, Trash2, Undo2, X } from "lucide-react";
|
|
import { api, bytes, type AdminUser } from "@/lib/api";
|
|
import { useToast } from "@/components/toast";
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export function UsersTable({
|
|
users,
|
|
roles,
|
|
me,
|
|
refresh,
|
|
}: {
|
|
users: AdminUser[];
|
|
roles: string[];
|
|
me: string | null;
|
|
refresh: () => void;
|
|
}) {
|
|
const [adding, setAdding] = React.useState(false);
|
|
|
|
return (
|
|
<div className="glass rounded-panel p-5">
|
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
|
<div>
|
|
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
|
|
Users
|
|
</h3>
|
|
<p className="mt-1 max-w-[62ch] text-callout text-dim">
|
|
Adding someone here <em>is</em> the authorisation, and it binds them to that
|
|
one provider. If SMTP is set up they also get an email; the link in it
|
|
carries no token, because possession of the mail isn’t what lets
|
|
anyone in — the row is.
|
|
</p>
|
|
</div>
|
|
<Button size="pill" variant={adding ? "ghost" : "primary"} onClick={() => setAdding(!adding)}>
|
|
{adding ? <X className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
|
{adding ? "Cancel" : "Invite"}
|
|
</Button>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{adding && (
|
|
<InviteForm
|
|
roles={roles}
|
|
onDone={() => {
|
|
setAdding(false);
|
|
refresh();
|
|
}}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<div className="mt-3 flex flex-col gap-2">
|
|
<AnimatePresence initial={false}>
|
|
{users.map((u) => (
|
|
<UserRow key={u.username} u={u} roles={roles} isMe={u.username === me} refresh={refresh} />
|
|
))}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UserRow({
|
|
u,
|
|
roles,
|
|
isMe,
|
|
refresh,
|
|
}: {
|
|
u: AdminUser;
|
|
roles: string[];
|
|
isMe: boolean;
|
|
refresh: () => void;
|
|
}) {
|
|
const toast = useToast();
|
|
const [busy, setBusy] = React.useState(false);
|
|
const [confirmDel, setConfirmDel] = React.useState(false);
|
|
|
|
const run = async (fn: () => Promise<unknown>, ok: string) => {
|
|
setBusy(true);
|
|
try {
|
|
await fn();
|
|
toast(ok);
|
|
refresh();
|
|
} catch (e) {
|
|
toast((e as Error).message, true);
|
|
}
|
|
setBusy(false);
|
|
};
|
|
|
|
const quotaPct = u.quota ? (u.usage.requests / u.quota) * 100 : null;
|
|
|
|
return (
|
|
<motion.div
|
|
layout
|
|
initial={{ opacity: 0, y: 8 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.98 }}
|
|
transition={{ type: "spring", stiffness: 360, damping: 32 }}
|
|
className={cn(
|
|
"glass-hi rounded-glass p-3",
|
|
u.disabled && "opacity-60",
|
|
)}
|
|
>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="h-9 w-9 shrink-0 overflow-hidden rounded-full bg-faint">
|
|
{u.avatar && (
|
|
/* eslint-disable-next-line @next/next/no-img-element */
|
|
<img
|
|
src={u.avatar}
|
|
alt=""
|
|
className="h-full w-full object-cover"
|
|
onError={(e) => (e.currentTarget.style.visibility = "hidden")}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-1.5 text-body font-semibold clamp1">
|
|
{u.display || u.username}
|
|
{u.role === "admin" && <ShieldCheck className="h-3.5 w-3.5 shrink-0 text-accent" />}
|
|
{isMe && <span className="text-caption font-normal text-dim">(you)</span>}
|
|
</div>
|
|
<div className="text-caption text-dim clamp1">
|
|
{u.username} · {u.provider}
|
|
{u.last_seen && ` · seen ${u.last_seen.slice(0, 10)}`}
|
|
{u.disabled && " · disabled"}
|
|
</div>
|
|
</div>
|
|
|
|
<select
|
|
value={u.role}
|
|
disabled={busy}
|
|
onChange={(e) => run(() => api.admin.patchUser(u.username, { role: e.target.value }), `${u.username} is now ${e.target.value}`)}
|
|
aria-label={`Role for ${u.username}`}
|
|
className="shrink-0 cursor-pointer rounded-full border border-edge-hi bg-transparent
|
|
px-2.5 py-1 text-caption text-ink outline-none
|
|
focus:border-accent-line"
|
|
>
|
|
{roles.map((r) => (
|
|
<option key={r} value={r} className="bg-[#12141c]">
|
|
{r}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Never signed in and we have somewhere to write: the likeliest
|
|
reason is they missed the mail, so offer it where that is visible.
|
|
Resending is harmless — the link carries no token. */}
|
|
{u.email && !u.last_seen && (
|
|
<Button
|
|
size="sm"
|
|
variant="quiet"
|
|
disabled={busy}
|
|
onClick={() =>
|
|
run(() => api.admin.resend(u.username), `Invite resent to ${u.email}`)
|
|
}
|
|
title={`Resend the invitation to ${u.email}`}
|
|
>
|
|
<Send className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
|
|
<Button
|
|
size="sm"
|
|
variant="quiet"
|
|
disabled={busy}
|
|
onClick={() =>
|
|
run(
|
|
() => api.admin.patchUser(u.username, { disabled: !u.disabled }),
|
|
u.disabled ? `${u.username} re-enabled` : `${u.username} disabled`,
|
|
)
|
|
}
|
|
title={u.disabled ? "Re-enable" : "Disable and revoke sessions"}
|
|
>
|
|
{u.disabled ? <Undo2 className="h-3.5 w-3.5" /> : <Ban className="h-3.5 w-3.5" />}
|
|
</Button>
|
|
|
|
{confirmDel ? (
|
|
<div className="flex shrink-0 gap-1">
|
|
<Button
|
|
size="sm"
|
|
variant="quiet"
|
|
disabled={busy}
|
|
className="border-err/50 text-err"
|
|
onClick={() => run(() => api.admin.deleteUser(u.username), `${u.username} deleted`)}
|
|
>
|
|
<Check className="h-3.5 w-3.5" /> Delete
|
|
</Button>
|
|
<Button size="sm" variant="quiet" onClick={() => setConfirmDel(false)}>
|
|
<X className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
variant="quiet"
|
|
disabled={busy}
|
|
onClick={() => setConfirmDel(true)}
|
|
title="Delete user (their request history is kept)"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-2.5 flex flex-wrap items-center gap-x-5 gap-y-2 pl-12 text-caption">
|
|
<QuotaField
|
|
label="Requests / month"
|
|
value={u.quota}
|
|
suffix={`used ${u.usage.requests}`}
|
|
pct={quotaPct}
|
|
onSave={(v) => run(() => api.admin.patchUser(u.username, { quota: v }), "Quota updated")}
|
|
/>
|
|
<QuotaField
|
|
label="Storage (GB)"
|
|
value={+(u.storage_quota / 2 ** 30).toFixed(1)}
|
|
suffix={`used ${bytes(u.usage.storage)}`}
|
|
pct={u.storage_quota ? (u.usage.storage / u.storage_quota) * 100 : null}
|
|
onSave={(v) =>
|
|
run(() => api.admin.patchUser(u.username, { storage_quota_gb: v }), "Quota updated")
|
|
}
|
|
/>
|
|
{u.bootstrap && (
|
|
<span className="text-caption text-warn" title="Listed in MUSE_ALLOWED_USERS">
|
|
bootstrap account — disabling won’t lock them out
|
|
</span>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
/** Number input that only commits on blur or Enter, so a quota isn't PATCHed
|
|
* once per keystroke while you type "120". */
|
|
function QuotaField({
|
|
label,
|
|
value,
|
|
suffix,
|
|
pct,
|
|
onSave,
|
|
}: {
|
|
label: string;
|
|
value: number;
|
|
suffix: string;
|
|
pct: number | null;
|
|
onSave: (v: number) => void;
|
|
}) {
|
|
const [v, setV] = React.useState(String(value));
|
|
React.useEffect(() => setV(String(value)), [value]);
|
|
|
|
const commit = () => {
|
|
const n = Number(v);
|
|
if (!Number.isFinite(n) || n < 0) {
|
|
setV(String(value));
|
|
return;
|
|
}
|
|
if (n !== value) onSave(n);
|
|
};
|
|
|
|
return (
|
|
<label className="flex items-center gap-2">
|
|
<span className="text-dim">{label}</span>
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
step="any"
|
|
value={v}
|
|
onChange={(e) => setV(e.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={(e) => e.key === "Enter" && (e.target as HTMLInputElement).blur()}
|
|
className="w-16 rounded-md border border-edge-hi bg-transparent px-1.5 py-0.5
|
|
text-right text-ink outline-none focus:border-accent-line"
|
|
/>
|
|
<span className={cn("text-dim", pct != null && pct >= 90 && "text-err")}>
|
|
{Number(v) === 0 ? "unlimited" : suffix}
|
|
</span>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void }) {
|
|
const toast = useToast();
|
|
const [username, setUsername] = React.useState("");
|
|
// Google first: it is the invite path for everyone who is not the owner,
|
|
// and its username is already the address to write to.
|
|
const [provider, setProvider] = React.useState("google");
|
|
const [role, setRole] = React.useState("user");
|
|
const [quota, setQuota] = React.useState("0");
|
|
const [storage, setStorage] = React.useState("0");
|
|
const [email, setEmail] = React.useState("");
|
|
const [busy, setBusy] = React.useState(false);
|
|
|
|
const submit = async () => {
|
|
if (!username.trim()) {
|
|
toast("Username required", true);
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
try {
|
|
const r = await api.admin.addUser({
|
|
username: username.trim(),
|
|
provider,
|
|
role,
|
|
quota: Number(quota) || 0,
|
|
storage_quota_gb: Number(storage) || 0,
|
|
...(provider === "github" && email.trim() ? { email: email.trim() } : {}),
|
|
});
|
|
// Say whether the mail actually went. "Invited" alone would read as
|
|
// success while the person waits for an email that was never sent.
|
|
toast(
|
|
r.invite_sent
|
|
? `Invited ${username.trim()} — email sent`
|
|
: `Invited ${username.trim()} — no email (${r.invite_why})`,
|
|
);
|
|
onDone();
|
|
} catch (e) {
|
|
toast((e as Error).message, true);
|
|
}
|
|
setBusy(false);
|
|
};
|
|
|
|
const input =
|
|
"rounded-glass border border-edge-hi bg-transparent px-3 py-2 text-callout " +
|
|
"text-ink outline-none focus:border-accent-line";
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, height: 0 }}
|
|
animate={{ opacity: 1, height: "auto" }}
|
|
exit={{ opacity: 0, height: 0 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="glass-hi mt-1 flex flex-wrap items-end gap-2.5 rounded-glass p-3">
|
|
<label className="flex min-w-[210px] flex-1 flex-col gap-1">
|
|
<span className="text-caption text-dim">
|
|
{provider === "github" ? "GitHub username" : "Google email"}
|
|
</span>
|
|
<input
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && submit()}
|
|
placeholder={provider === "github" ? "octocat" : "friend@gmail.com"}
|
|
className={input}
|
|
autoFocus
|
|
/>
|
|
</label>
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-caption text-dim">Provider</span>
|
|
<select value={provider} onChange={(e) => setProvider(e.target.value)} className={cn(input, "cursor-pointer")}>
|
|
<option value="google" className="bg-[#12141c]">Google</option>
|
|
<option value="github" className="bg-[#12141c]">GitHub</option>
|
|
</select>
|
|
</label>
|
|
{/* Only GitHub needs this asked. A Google invite already is an
|
|
address, and a second field for the same value invites them to
|
|
disagree. */}
|
|
{provider === "github" && (
|
|
<label className="flex min-w-[190px] flex-col gap-1">
|
|
<span className="text-caption text-dim">Email them at (optional)</span>
|
|
<input
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && submit()}
|
|
placeholder="octocat@example.com"
|
|
className={input}
|
|
/>
|
|
</label>
|
|
)}
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-caption text-dim">Role</span>
|
|
<select value={role} onChange={(e) => setRole(e.target.value)} className={cn(input, "cursor-pointer")}>
|
|
{roles.map((r) => (
|
|
<option key={r} value={r} className="bg-[#12141c]">
|
|
{r}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="flex w-24 flex-col gap-1">
|
|
<span className="text-caption text-dim">Req/month</span>
|
|
<input type="number" min={0} value={quota} onChange={(e) => setQuota(e.target.value)} className={input} />
|
|
</label>
|
|
<label className="flex w-24 flex-col gap-1">
|
|
<span className="text-caption text-dim">Storage GB</span>
|
|
<input type="number" min={0} value={storage} onChange={(e) => setStorage(e.target.value)} className={input} />
|
|
</label>
|
|
<Button onClick={submit} disabled={busy}>
|
|
{busy ? "Inviting…" : "Invite"}
|
|
</Button>
|
|
<span className="w-full text-caption text-dim">0 means unlimited.</span>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
}
|