Skip to content

Commit c599a92

Browse files
authored
Forgot password says what happened and who sends it; People shows who hasn't signed in here (#311)
* Sign-in says what forgot-password did and who sends it; People shows who hasn't signed in here Forgot password shows the install's answer for that email (a reset code, a set-up link instead, an invite to use, or no account here) and names the sender to look for, so a person can ask IT to allow it. Members and roles: someone copied from the previous CoderHelm shows "Hasn't signed in here yet" instead of their old activity time, with a Send set-up email button on the row and "Can sign in with GitHub" when it works. A banner counts them and sends set-up emails to all. * Wide-data answers /auth/methods and has a member who hasn't signed in here
1 parent b6fd1b5 commit c599a92

8 files changed

Lines changed: 325 additions & 26 deletions

File tree

‎dashboard/e2e/sign-in-methods.spec.ts‎

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import { expect, test, type Page, type Route } from "@playwright/test";
77
// install checks the account itself. An install with neither shows no GitHub
88
// button. Every API call is answered here.
99

10-
type Methods = { password: boolean; google: boolean; github: boolean };
10+
type Methods = { password: boolean; google: boolean; github: boolean; senders?: { setup: string | null; reset_codes: string | null } };
11+
type ForgotReply = { status: string; message: string; email?: string };
1112

12-
async function mockApi(page: Page, opts: { selfHosted: boolean; methods: Methods | "missing"; status?: string }) {
13+
async function mockApi(page: Page, opts: { selfHosted: boolean; methods: Methods | "missing"; status?: string; forgot?: ForgotReply }) {
1314
const calls: string[] = [];
1415
const json = (route: Route, body: unknown, status = 200) =>
1516
route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
@@ -31,6 +32,7 @@ async function mockApi(page: Page, opts: { selfHosted: boolean; methods: Methods
3132
? json(route, { user_id: "u_1", team_id: "t_1", github_login: "a-dev", email: "dev@acme.example", avatar_url: "", role: "owner", status: opts.status })
3233
: json(route, { error: "unauthorized" }, 401);
3334
}
35+
if (p === "/auth/forgot-password" && opts.forgot) return json(route, opts.forgot);
3436
if (p === "/api/usage") return json(route, { month: "2026-01", tokens_in: 0, tokens_out: 0, total_runs: 0, total_tokens: 0, max_tokens: 0 });
3537
if (p === "/api/banners") return json(route, { banners: [] });
3638
if (p === "/api/teams") return json(route, { teams: [{ team_id: "t_1", org: "acme", status: "deactivated", current: true }] });
@@ -124,3 +126,69 @@ test("removed GitHub app: 'sign in with GitHub again' only where GitHub sign-in
124126
await page.goto("/");
125127
await expect(page.getByRole("link", { name: "Then sign in with GitHub again" })).toHaveAttribute("href", /^\/auth\/github$/);
126128
});
129+
130+
// Forgot password on a self-hosted install says what happened for that email, and where the
131+
// email comes from, so someone whose mail filter holds it back can ask IT to allow it.
132+
const SENDERS = { setup: "coderhelm@acme.example", reset_codes: "no-reply@verificationemail.com" };
133+
134+
async function askForReset(page: Page, email: string) {
135+
await page.goto("/");
136+
await page.getByRole("button", { name: "Forgot password?" }).click();
137+
await page.getByLabel("Email").fill(email);
138+
await page.getByRole("button", { name: "Send", exact: true }).click();
139+
}
140+
141+
test("forgot password, account here: the code is on its way from Amazon Cognito's address", async ({ page }) => {
142+
const { calls } = await mockApi(page, {
143+
selfHosted: true,
144+
methods: { password: true, google: false, github: false, senders: SENDERS },
145+
forgot: { status: "code_sent", message: "We sent a reset code to jo@acme.example.", email: "jo@acme.example" },
146+
});
147+
await askForReset(page, "jo@acme.example");
148+
await expect(page.getByRole("status").filter({ hasText: "sent" })).toHaveText("We sent a reset code to jo@acme.example.");
149+
await expect(page.getByTestId("sender-note")).toContainText(
150+
"The reset code comes from no-reply@verificationemail.com (Amazon Cognito, which signs people in here).",
151+
);
152+
await expect(page.getByTestId("sender-note")).toContainText("ask IT to allow that address");
153+
await expect(page.getByLabel("Reset code")).toBeVisible();
154+
expect(calls).toContain("/auth/forgot-password");
155+
});
156+
157+
test("forgot password, on the team with no account here: a set-up link instead, from the install's sender", async ({ page }) => {
158+
await mockApi(page, {
159+
selfHosted: true,
160+
methods: { password: true, google: false, github: false, senders: SENDERS },
161+
forgot: { status: "setup_sent", message: "You don't have an account here yet. We sent you a set-up link instead." },
162+
});
163+
await askForReset(page, "al@acme.example");
164+
await expect(page.getByRole("status").filter({ hasText: "sent" })).toHaveText("You don't have an account here yet. We sent you a set-up link instead.");
165+
await expect(page.getByTestId("sender-note")).toContainText("The set-up link comes from coderhelm@acme.example.");
166+
await expect(page.getByTestId("sender-note")).not.toContainText("verificationemail.com");
167+
// No code to type: the link in the email sets up the account.
168+
await expect(page.getByLabel("Reset code")).toHaveCount(0);
169+
});
170+
171+
test("forgot password, not on this install: says so, sends nothing, no sender line", async ({ page }) => {
172+
await mockApi(page, {
173+
selfHosted: true,
174+
methods: { password: true, google: false, github: false, senders: SENDERS },
175+
forgot: { status: "no_account", message: "There's no account for who@else.example on this CoderHelm. Ask an admin to add you." },
176+
});
177+
await askForReset(page, "who@else.example");
178+
await expect(page.getByRole("alert").filter({ hasText: "no account" })).toHaveText(
179+
"There's no account for who@else.example on this CoderHelm. Ask an admin to add you.",
180+
);
181+
await expect(page.getByTestId("sender-note")).toHaveCount(0);
182+
await expect(page.getByLabel("Reset code")).toHaveCount(0);
183+
});
184+
185+
test("forgot password with an SES sender for Cognito: that address, not Cognito's", async ({ page }) => {
186+
await mockApi(page, {
187+
selfHosted: true,
188+
methods: { password: true, google: false, github: false, senders: { setup: "coderhelm@acme.example", reset_codes: "noreply@acme.example" } },
189+
forgot: { status: "code_sent", message: "We sent a reset code to jo@acme.example." },
190+
});
191+
await askForReset(page, "jo@acme.example");
192+
await expect(page.getByTestId("sender-note")).toContainText("The reset code comes from noreply@acme.example.");
193+
await expect(page.getByTestId("sender-note")).not.toContainText("Amazon Cognito");
194+
});

‎dashboard/e2e/team.spec.ts‎

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ interface Backend {
111111
changes: { at: string; actor: string; action: string; role_id: string; role_name: string; detail: string }[];
112112
/** You hold this custom role (your record's role); requests run as `selfRole`. */
113113
selfCustom?: string;
114+
/** GitHub sign-in works here (`GET /auth/methods`). */
115+
githubSignIn?: boolean;
114116
}
115117

116118
// The gateway's rules for custom roles, as the mock answers them.
@@ -140,6 +142,22 @@ async function mockApi(page: Page, selfRole = "admin", init: Partial<Backend> =
140142
const body = req.postData() ? (JSON.parse(req.postData() as string) as Record<string, unknown>) : null;
141143
db.calls.push({ method, path: p, body, search: url.search });
142144
const self = db.members.find((m) => m.is_self);
145+
if (p === "/auth/methods") return json(route, { password: true, google: false, github: db.githubSignIn ?? false });
146+
if (p === "/api/users/setup-emails" && method === "POST") {
147+
if (LEVEL[selfRole] < 3) return json(route, { error: "Only admins and owners can do that." }, 403);
148+
const hour = Date.now() - 3600_000;
149+
const sent_at: Record<string, string> = {};
150+
let skipped = 0;
151+
for (const m of db.members.filter((x) => x.needs_setup)) {
152+
if (m.setup_email_sent_at && new Date(m.setup_email_sent_at).getTime() > hour) {
153+
skipped += 1;
154+
continue;
155+
}
156+
m.setup_email_sent_at = new Date().toISOString();
157+
sent_at[m.user_id] = m.setup_email_sent_at;
158+
}
159+
return json(route, { sent: Object.keys(sent_at).length, skipped_recent: skipped, failed: 0, sent_at });
160+
}
143161
if (p === "/api/me") return json(route, { user_id: self?.user_id ?? "u-admin", team_id: "t_1", github_login: null, email: self?.email ?? "", avatar_url: "", role: selfRole });
144162
if (p === "/api/usage") return json(route, { month: "2026-01", total_tokens: 0, tokens_in: 0, tokens_out: 0, total_runs: 0, max_tokens: 0 });
145163
if (p === "/api/banners") return json(route, { banners: [] });
@@ -483,20 +501,79 @@ test("self-hosted: a member who never signed in here gets a set-up email from th
483501
const mia = db.members.find((x) => x.user_id === "u-mem")!;
484502
mia.needs_setup = true;
485503
await page.goto("/settings/team");
486-
await expect(row(page, "Mia Member").getByTestId("setup-status")).toHaveText("Hasn't set up an account here yet");
504+
await expect(row(page, "Mia Member").getByTestId("setup-status")).toHaveText("Hasn't signed in here yet");
487505
// Only that member: everyone else has signed in here.
488506
await expect(members(page).getByTestId("setup-status")).toHaveCount(1);
507+
await expect(row(page, "Mia Member").getByRole("button", { name: "Send set-up email" })).toBeVisible();
489508
await row(page, "Mia Member").getByRole("button", { name: "More actions for Mia Member" }).click();
490509
await page.getByRole("menuitem", { name: "Send set-up email" }).click();
491510
await expect(page.getByText("Set-up email sent to mia@example.com")).toBeVisible();
492-
await expect(row(page, "Mia Member").getByTestId("setup-status")).toHaveText(/^Set-up email sent /);
511+
await expect(row(page, "Mia Member").getByTestId("setup-sent")).toHaveText(/^Set-up email sent /);
512+
await expect(row(page, "Mia Member").getByRole("button", { name: "Send the set-up email again" })).toBeVisible();
493513
expect(db.calls.some((c) => c.method === "POST" && c.path === "/api/users/u-mem/setup-email")).toBe(true);
494514
// Sent before: the menu says it goes again.
495515
await row(page, "Mia Member").getByRole("button", { name: "More actions for Mia Member" }).click();
496516
await expect(page.getByRole("menuitem", { name: "Send the set-up email again" })).toBeVisible();
497517
await shot(page, "team-setup-email");
498518
});
499519

520+
test("self-hosted: someone copied over shows they haven't signed in here, not their old activity", async ({ page }) => {
521+
const db = await mockApi(page, "admin", { githubSignIn: true });
522+
db.members.push(
523+
member({ user_id: "u-al", email: "al.chan@acme.example", name: "Al Chan", github_login: "achan", github_id: "777", sign_in: "email_and_github", last_active_at: ago(5 * DAY), needs_setup: true }),
524+
);
525+
await page.goto("/settings/team");
526+
const al = row(page, "Al Chan");
527+
await expect(al.getByTestId("setup-status")).toHaveText("Hasn't signed in here yet");
528+
await expect(al).not.toContainText("Active 5 days ago");
529+
await expect(al.getByTestId("previous-activity")).toHaveText("Last active on the previous Coderhelm, 5 days ago");
530+
await expect(al.getByTestId("github-sign-in")).toHaveText("Can sign in with GitHub (achan)");
531+
await expect(al.getByRole("button", { name: "Send set-up email" })).toBeVisible();
532+
// People who signed in here keep their plain activity time.
533+
await expect(row(page, "Sam Rivera")).toContainText("Active 5 days ago");
534+
await expect(page.getByText("· 1 haven't signed in here yet")).toBeVisible();
535+
});
536+
537+
test("self-hosted: without GitHub sign-in, no GitHub line for someone copied over", async ({ page }) => {
538+
const db = await mockApi(page, "admin", { githubSignIn: false });
539+
db.members.push(member({ user_id: "u-al", email: "al.chan@acme.example", name: "Al Chan", github_login: "achan", github_id: "777", needs_setup: true }));
540+
await page.goto("/settings/team");
541+
await expect(row(page, "Al Chan").getByTestId("setup-status")).toBeVisible();
542+
await expect(row(page, "Al Chan").getByTestId("github-sign-in")).toHaveCount(0);
543+
});
544+
545+
test("self-hosted: the banner counts who hasn't set up an account and sends to all, skipping the last hour", async ({ page }) => {
546+
const db = await mockApi(page);
547+
const mia = db.members.find((x) => x.user_id === "u-mem")!;
548+
const jo = db.members.find((x) => x.user_id === "u-jo")!;
549+
const sam = db.members.find((x) => x.user_id === "u-sam")!;
550+
mia.needs_setup = true;
551+
sam.needs_setup = true;
552+
jo.needs_setup = true;
553+
jo.setup_email_sent_at = ago(20 * 60_000);
554+
await page.goto("/settings/team");
555+
const banner = page.getByTestId("setup-banner");
556+
await expect(banner).toContainText("3 people haven't set up an account here yet");
557+
await expect(page.getByText("· 3 haven't signed in here yet")).toBeVisible();
558+
await banner.getByRole("button", { name: "Send set-up emails to all" }).click();
559+
await expect(page.getByText("Set-up email sent to 2 people. 1 person got one in the last hour and was skipped.")).toBeVisible();
560+
expect(db.calls.filter((c) => c.method === "POST" && c.path === "/api/users/setup-emails")).toHaveLength(1);
561+
await expect(row(page, "Mia Member").getByTestId("setup-sent")).toHaveText(/^Set-up email sent /);
562+
await expect(row(page, "Sam Rivera").getByTestId("setup-sent")).toHaveText(/^Set-up email sent /);
563+
await shot(page, "team-setup-banner");
564+
});
565+
566+
test("the set-up banner has no send button for someone who can't invite", async ({ page }) => {
567+
const db = await mockApi(page, "member");
568+
db.members.find((x) => x.user_id === "u-sam")!.needs_setup = true;
569+
await page.goto("/settings/team");
570+
const banner = page.getByTestId("setup-banner");
571+
await expect(banner).toContainText("1 person hasn't set up an account here yet");
572+
await expect(banner).toContainText("Ask an admin to send them the set-up email.");
573+
await expect(banner.getByRole("button", { name: "Send set-up emails to all" })).toHaveCount(0);
574+
await expect(row(page, "Sam Rivera").getByRole("button", { name: "Send set-up email" })).toHaveCount(0);
575+
});
576+
500577
test("no set-up email for members who signed in, or for someone who can't invite", async ({ page }) => {
501578
await mockApi(page);
502579
await page.goto("/settings/team");

‎dashboard/e2e/wide-data/settings.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ const USERS = [
1919
{ user_id: "u_owner", email: EMAIL, name: "Olivia Owner-With-A-Very-Long-Double-Barrelled-Surname", github_login: "olivia-owner-with-a-long-github-login", github_id: null, avatar_url: null, role: "owner", status: "active", sign_in: "email", invited_by: null, last_active_at: iso(60), updated_at: iso(600), is_self: false },
2020
{ user_id: "u_self", email: `dana.${EMAIL}`, name: "Dana Developer", github_login: "dana-developer-with-a-long-login", github_id: null, avatar_url: null, role: "owner", status: "active", sign_in: "github", invited_by: null, last_active_at: iso(5), updated_at: iso(600), is_self: true },
2121
{ user_id: "u_inv", email: `invited.person.${EMAIL}`, name: null, github_login: null, github_id: null, avatar_url: null, role: "member", status: "invited", sign_in: "email", invited_by: EMAIL, last_active_at: null, updated_at: iso(60 * 24), is_self: false },
22+
// Copied from another Coderhelm and never signed in here: the status, the old activity, the GitHub line and the row button.
23+
{ user_id: "u_moved", email: `moved.person.${EMAIL}`, name: "Moved Person-With-A-Very-Long-Double-Barrelled-Surname", github_login: "moved-person-with-a-long-github-login", github_id: "4242", avatar_url: null, role: "member", status: "active", sign_in: "email_and_github", invited_by: null, last_active_at: iso(60 * 24 * 5), updated_at: iso(600), needs_setup: true, setup_email_sent_at: iso(60 * 24), is_self: false },
2224
{ user_id: "u_view", email: `viewer.${EMAIL}`, name: "Jo Viewer", github_login: null, github_id: null, avatar_url: null, role: "viewer", status: "active", sign_in: "email", invited_by: null, last_active_at: iso(60 * 24 * 9), updated_at: iso(60 * 24 * 9), is_self: false },
2325
];
2426

@@ -236,6 +238,7 @@ export const settingsData: Handler = ({ method, path, url }) => {
236238
headers: { "webhook-id": "evt_01JBEVNT8M1N4P7Q0R3S6T9V2WWIDEWIDEWIDE", "webhook-signature": "[redacted]", "user-agent": LONG_URL },
237239
}] };
238240
}
241+
if (p === "/auth/methods") return { password: true, google: false, github: true, senders: { setup: `coderhelm.${EMAIL}`, reset_codes: "no-reply@verificationemail.com" } };
239242
if (p === "/api/users") return { users: USERS };
240243
// Your own two-factor sign-in (both deployments): kept on by a team's rule.
241244
if (p === "/api/users/2fa") {

0 commit comments

Comments
 (0)