Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
70abbf2
docs: scope reviewer invitation and access fixes
Muawiya-contact Oct 21, 2026
e4f0cab
email: add reviewer invitation template and callback guide
Muawiya-contact Oct 21, 2026
23fd3ce
editorial: cap active reviewers and default new MFA off
Muawiya-contact Oct 22, 2026
0670ffb
editorial: permanently remove reviewer access without deleting learners
Muawiya-contact Oct 22, 2026
66a8bd0
email: match assignment instructions to optional authenticator
Muawiya-contact Oct 22, 2026
2077536
test: cover full reviewer team responses in Edge API
Muawiya-contact Oct 22, 2026
5d3e687
test: allow reviewer deletion in browser CORS fixture
Muawiya-contact Oct 22, 2026
78f8821
schema: review reviewer lifecycle migration contract
Muawiya-contact Oct 22, 2026
1adc930
docs: explain reviewer lifecycle and live invitation activation
Muawiya-contact Oct 22, 2026
d1d1520
Resend invitations for unconfirmed reviewer identities
Muawiya-contact Oct 22, 2026
4668eaf
Add verified reviewer account setup page
Muawiya-contact Oct 22, 2026
ddcc3cf
Clarify comment and AI correction actions
Muawiya-contact Oct 22, 2026
4d5b97d
Explain shared learner identity in reviewer email
Muawiya-contact Oct 22, 2026
51ec099
Document reviewer setup audit and remaining worker work
Muawiya-contact Oct 22, 2026
a90eda1
Handle callback sessions and sign-out edge cases
Muawiya-contact Oct 22, 2026
d6b7489
Wait for sign-in before reading callback test tokens
Muawiya-contact Oct 22, 2026
f62781b
Record hosted callback test race and correction
Muawiya-contact Oct 22, 2026
2b2e2f2
Record final reviewer onboarding quality run
Muawiya-contact Oct 22, 2026
a886f69
Resend Edge reviewer invites for unconfirmed accounts
Muawiya-contact Oct 22, 2026
928545f
Document unconfirmed reviewer invite rollout
Muawiya-contact Oct 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions admin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,14 @@ actual HTTPS origin; local fixtures do not prove them.

## Reviewer journey

1. The owner invites an individual email; each person sets their own password.
1. The owner adds a reviewer by email. A new One Concept account receives an
invitation to this website and sets a password here. An existing confirmed
learner uses their current email and password. The team allows 10 active
reviewers in total, including the owner.
2. The person submits their registered name. An authorized owner approves it.
3. They verify an authenticator before opening private editorial content.
3. Authenticator use starts off for new reviewer memberships. A reviewer may
turn on the requirement in **Settings**; when on, they verify a code before
opening private editorial content.
4. They filter **Review queue** by topic, subtopic, status, assigned reviewer,
deadline or title. Counts cover all matching records, independently of pages.
5. They open the complete explanation, example, curriculum, references,
Expand Down Expand Up @@ -94,8 +99,11 @@ the publication checklist.
- Owners assign a reviewer and an optional deadline with an audited note.
Dates are entered/displayed in the browser's local timezone and stored in UTC.
Deadlines never auto-approve content. Deadline notification emails are #279.
- **Reviewers** manages invitations, name approval, explicit permissions and
revocation. Server safeguards protect the last owner and stale account edits.
- **Reviewers** manages invitations, name approval, explicit permissions,
revocation, and permanent reviewer-access removal. Removal preserves the
person's learner account and historical review evidence, clears open review
assignments, and blocks further workspace access. Server safeguards protect
the last administrator and stale account edits.
- **Settings** shows the approved identity, pending name, environment and access.
Name changes affect future signatures; historical evidence remains immutable.

Expand Down
33 changes: 19 additions & 14 deletions admin/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { Session, SupabaseClient } from "@supabase/supabase-js";
import { Api, ApiError } from "./api";
import { Login, Mfa } from "./Auth";
import { AccountSetup, Login, Mfa } from "./Auth";
import { Notice, message } from "./ui";
import type { Me } from "./types";
import { Settings } from "./Settings";
Expand All @@ -28,13 +28,13 @@ export function App({
auth,
api,
environment,
passwordSetup: initialSetup = false,
passwordSetup: initialSetup = null,
initialError = "",
}: {
auth: SupabaseClient;
api: Api;
environment: string;
passwordSetup?: boolean;
passwordSetup?: "invite" | "recovery" | "setup" | null;
initialError?: string;
}) {
const [session, setSession] = useState<Session | null>(null),
Expand Down Expand Up @@ -86,7 +86,8 @@ export function App({
dirty.current = false;
setEpoch((n) => n + 1);
}
if (event === "PASSWORD_RECOVERY") setSetup(true);
if (event === "SIGNED_OUT") setSetup(null);
if (event === "PASSWORD_RECOVERY") setSetup("recovery");
});
api.onDenied = (status) => {
api.setToken(
Expand Down Expand Up @@ -196,19 +197,23 @@ export function App({
Opening One Concept Review…
</div>
);
if (!session || setup)
if (session && setup)
return (
<AccountSetup
auth={auth}
email={session.user.email || ""}
flow={setup}
onPasswordSet={() => {
setSetup(null);
navigate({ view: "queue" });
}}
/>
);
if (!session)
return (
<>
{error && <Notice error>{error}</Notice>}
<Login
key={epoch}
auth={auth}
passwordSetup={setup && !!session}
onPasswordSet={() => {
setSetup(false);
navigate({ view: "queue" });
}}
/>
<Login key={epoch} auth={auth} />
</>
);
const can = (cap: string) => !!me?.member.capabilities.includes(cap as never);
Expand Down
122 changes: 80 additions & 42 deletions admin/src/Auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,8 @@ import type { SupabaseClient } from "@supabase/supabase-js";
import { Field, Notice, message } from "./ui";
export function Login({
auth,
passwordSetup,
onPasswordSet,
}: {
auth: SupabaseClient;
passwordSetup: boolean;
onPasswordSet: () => void;
}) {
const [email, setEmail] = useState(""),
[password, setPassword] = useState(""),
Expand All @@ -22,24 +18,19 @@ export function Login({
setError("");
setNotice("");
try {
const result = passwordSetup
? await auth.auth.updateUser({ password })
: recovery
? await auth.auth.resetPasswordForEmail(email, {
redirectTo: location.origin + "/auth/callback",
})
: await auth.auth.signInWithPassword({ email, password });
const result = recovery
? await auth.auth.resetPasswordForEmail(email, {
redirectTo: location.origin + "/auth/callback",
})
: await auth.auth.signInWithPassword({ email, password });
if (result.error)
throw new Error(
passwordSetup
? "Password could not be updated. Check password requirements and try again."
: recovery
? "Unable to request a recovery email. Try again later."
: "Unable to sign in. Check your credentials or contact your administrator.",
recovery
? "Unable to request a recovery email. Try again later."
: "Unable to sign in. Check your credentials or contact your administrator.",
);
setPassword("");
if (passwordSetup) onPasswordSet();
else if (recovery)
if (recovery)
setNotice(
"If this address can recover an account, a password reset email will arrive shortly.",
);
Expand Down Expand Up @@ -75,39 +66,29 @@ export function Login({
<form onSubmit={submit}>
<p className="eyebrow">INVITED REVIEWERS</p>
<h2>
{passwordSetup
? "Set your password"
: recovery
{recovery
? "Recover your account"
: "Welcome back"}
</h2>
<p>
{passwordSetup
? "Choose a private password. Your administrator never needs to know it."
: "Sign in with your own invited account to continue."}
</p>
<p>{recovery ? "Enter your account email to receive a password reset link." : "Sign in with your own invited account to continue."}</p>
{error && <Notice error>{error}</Notice>}
{notice && <Notice>{notice}</Notice>}
{!passwordSetup && (
<Field title="Email address">
<Field title="Email address">
<input
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</Field>
)}
{(!recovery || passwordSetup) && (
</Field>
{!recovery && (
<Field title="Password">
<input
type="password"
autoComplete={
passwordSetup ? "new-password" : "current-password"
}
autoComplete="current-password"
required
minLength={passwordSetup ? 12 : 1}
minLength={1}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
Expand All @@ -116,14 +97,11 @@ export function Login({
<button className="primary" disabled={busy}>
{busy
? "Please wait…"
: passwordSetup
? "Save password"
: recovery
: recovery
? "Send recovery email"
: "Sign in"}
</button>
{!passwordSetup && (
<button
<button
type="button"
className="text-button"
onClick={() => {
Expand All @@ -134,8 +112,7 @@ export function Login({
}}
>
{recovery ? "Back to sign in" : "Forgot your password?"}
</button>
)}
</button>
</form>
<p className="muted">
Private workspace · Your learning app account does not automatically
Expand All @@ -145,6 +122,67 @@ export function Login({
</div>
);
}

export function AccountSetup({
auth,
email,
flow,
onPasswordSet,
}: {
auth: SupabaseClient;
email: string;
flow: "invite" | "recovery" | "setup";
onPasswordSet: () => void;
}) {
const [password, setPassword] = useState("");
const [confirmation, setConfirmation] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function submit(e: React.FormEvent) {
e.preventDefault();
if (password !== confirmation) {
setError("The passwords do not match.");
return;
}
setBusy(true);
setError("");
try {
const { error } = await auth.auth.updateUser({ password });
if (error)
throw new Error("Password could not be saved. Check password requirements and try again.");
setPassword("");
setConfirmation("");
onPasswordSet();
} catch (e) {
setError(message(e));
} finally {
setBusy(false);
}
}
return (
<main className="auth-layout">
<section className="auth-story">
<div className="brand"><span className="brand-mark">1</span> One Concept</div>
<p className="eyebrow">ONE ACCOUNT · TWO EXPERIENCES</p>
<h1>Learn and review<br />with one account.</h1>
<p>Your verified email and password work in both the One Concept learning app and this review workspace. Reviewer access is granted by the owner.</p>
</section>
<section className="auth-panel">
<form onSubmit={submit}>
<p className="eyebrow">{flow === "invite" ? "INVITATION ACCEPTED" : "ACCOUNT SECURITY"}</p>
<h2>{flow === "invite" ? "Finish creating your account" : "Set your password"}</h2>
<p>{flow === "invite" ? "Your email is confirmed. Choose a password to finish setup." : "Choose a new password for your One Concept account."}</p>
{error && <Notice error>{error}</Notice>}
<Field title="Verified email"><input type="email" value={email} readOnly /></Field>
<Field title="Password"><input type="password" autoComplete="new-password" minLength={12} required value={password} onChange={(e) => setPassword(e.target.value)} /></Field>
<Field title="Confirm password"><input type="password" autoComplete="new-password" minLength={12} required value={confirmation} onChange={(e) => setConfirmation(e.target.value)} /></Field>
<button className="primary" disabled={busy}>{busy ? "Saving…" : "Save password and continue"}</button>
</form>
<p className="muted">{flow === "invite" ? "After setup, submit your reviewer name for owner approval in Settings. Authenticator use starts off and can be enabled there." : "Your existing reviewer permissions and security settings remain in place."}</p>
</section>
</main>
);
}
export function Mfa({
auth,
onVerified,
Expand Down
7 changes: 7 additions & 0 deletions admin/src/Review.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,13 @@ export function Review({
onChange={(e) => setNote(e.target.value)}
/>
</Field>
{revision && detail.status === "pending_review" && (
<p className="muted">
Add comment saves feedback only. Request changes queues an AI
correction for the next worker run when generation is enabled.
The new draft still needs human review.
</p>
)}
{revision &&
["changes_requested", "validation_failed", "rejected"].includes(
detail.status,
Expand Down
2 changes: 1 addition & 1 deletion admin/src/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export function Settings({
Your name is waiting for an administrator’s approval.{" "}
{me.member.approved_name
? "Your current approved name still applies."
: "You can start reviewing after approval and authenticator verification."}
: "You can start reviewing after approval. If you require an authenticator, verify it too."}
</Notice>
)}
{saved && <Notice>Name submitted for approval.</Notice>}
Expand Down
28 changes: 25 additions & 3 deletions admin/src/Team.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function Team({ api, me }: { api: Api; me: Me }) {
await api.request("/reviewers", "POST", { email, capabilities: caps });
setEmail("");
setNotice(
"Membership created. New accounts receive an invitation; existing accounts use their own sign-in or recovery.",
"Reviewer access created. New users receive a link to set their password; existing One Concept users sign in with their current email and password.",
);
setRefresh((n) => n + 1);
} catch (e) {
Expand All @@ -49,8 +49,8 @@ export function Team({ api, me }: { api: Api; me: Me }) {
<section className="card">
<h2>Invite a reviewer</h2>
<p>
Reviewers set their own passwords. Start with your three-person team
and add others whenever needed.
Up to 10 active reviewers, including the owner, can use this workspace.
New users set their own password; existing One Concept users keep theirs.
</p>
{error && <Notice error>{error}</Notice>}
{notice && <Notice>{notice}</Notice>}
Expand Down Expand Up @@ -175,6 +175,23 @@ function MemberRow({
setBusy(false);
}
}
async function remove() {
if (!confirm(`Permanently remove reviewer access for ${member.invited_email}? Their One Concept learning account and past review history will remain.`))
return;
setBusy(true);
setError("");
try {
await api.request(
`/reviewers/${member.user_id}?expected_version=${member.version}`,
"DELETE",
);
changed();
} catch (e) {
setError(message(e));
} finally {
setBusy(false);
}
}
return (
<details className="member">
<summary>
Expand Down Expand Up @@ -208,6 +225,11 @@ function MemberRow({
<button disabled={busy} onClick={() => void save()}>
Save account access
</button>
{!self && (
<button type="button" disabled={busy} onClick={() => void remove()}>
Permanently remove reviewer
</button>
)}
</details>
);
}
2 changes: 2 additions & 0 deletions admin/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export class Api {
response.status === 409
? code === "review_conflict" && typeof detail.message === "string"
? detail.message
: typeof detail === "string"
? detail
: "This item changed or the action is no longer allowed. Reload and review the latest version before deciding."
: response.status === 422
? "Check the required fields and complete lesson package."
Expand Down
Loading
Loading