Bind invites to a provider, and email them

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>
This commit is contained in:
Aryan SinghandClaude Opus 5 committed 2026-08-05 12:05:44 -05:00
1 parent 205e0a44b8
commit da8bf67093
5 files changed
+434 -30

No files matched your search

+53 -8
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Ban, Check, Plus, ShieldCheck, Trash2, Undo2, X } from "lucide-react";
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";
@@ -28,9 +28,11 @@ export function UsersTable({
<h3 className="text-callout font-bold uppercase tracking-[0.9px] text-dim">
Users
</h3>
<p className="mt-1 text-callout text-dim">
Adding someone here <em>is</em> the invitation — no email, no token. They sign in
with that provider and they&rsquo;re in.
<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&rsquo;t what lets
anyone in — the row is.
</p>
</div>
<Button size="pill" variant={adding ? "ghost" : "primary"} onClick={() => setAdding(!adding)}>
@@ -145,6 +147,23 @@ function UserRow({
))}
</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"
@@ -266,10 +285,13 @@ function QuotaField({
function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void }) {
const toast = useToast();
const [username, setUsername] = React.useState("");
const [provider, setProvider] = React.useState("github");
// 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 () => {
@@ -279,14 +301,21 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
}
setBusy(true);
try {
await api.admin.addUser({
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() } : {}),
});
toast(`Invited ${username.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);
@@ -322,10 +351,26 @@ function InviteForm({ roles, onDone }: { roles: string[]; onDone: () => void })
<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="github" className="bg-[#12141c]">GitHub</option>
<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")}>