Skip to content

Commit

Email confirmation gate: a code and a link, nothing else until confirmed

A new account confirms its email address before it can do anything on g1t. The confirmation email carries a six-digit code (shown large, and in the subject) and a link, either one enough. Both live in one email_tokens row bound to the account and the address: the link's token hash as its id, the code as an HMAC under IDENTITY_KEY (migration 0036, code_hash). Using either deletes the row; a new email deletes the ones before. Both work for 60 minutes (was 24 hours for links). Codes are compared in constant time; wrong ones are throttled per account (10/hour) and per client (30/hour) with the password lockout's back-off, and get one generic answer. The gate: - Site: viewerMiddleware sends a pending account to /confirm-email from every page (data requests included) except signing in and out, resets, /verify, and the policies, security, support, status and pricing pages (lib/confirm-gate.ts). The page takes the code (one-time-code, numeric), sends a new one (1-minute rule), changes the address (only while no address is confirmed: change_pending_email), and signs out. - API: everything but GET /user, GET /user/emails and the new POST /user/emails/confirm answers 403 with "Confirm your email address first"; MCP answers 403 to everything. Git over HTTPS refuses with the same message; package registries treat the credentials as wrong; device sign-in cannot be approved by a pending account. Invites: signing up spends the code at once but joins nothing. The invite waits (invites.applied_at null; status awaiting_confirmation, revocable) and its workspace is joined in the same D1 batch that confirms the address. Revoked, expired, a deleted or free workspace: the address is confirmed anyway and the person is told the invite no longer applies. Email-bound invites no longer confirm the address at sign-up; addresses GitHub has verified still do. API: confirm_email (POST /user/emails/confirm, account confirm_email over MCP), returning username, email, verified, joined, invite_lapsed. Docs: Confirming your email address in the authentication guide, invites, workspaces, MCP reference, quickstart, self-hosting, llms.txt.

syntaqxcommitted Parent70b5eb9Browse files
49 files+276−340/49 viewed
+32−1
4444 ReportQueueArgs, ReportReviewArgs,
4545 };
4646 use g1t_contracts::identity::AgentScope;
47−use g1t_contracts::{Failure, FailureCode, Outcome, PrincipalKind, Viewer};
47+use g1t_contracts::{Failure, FailureCode, Outcome, PrincipalKind, User, Viewer};
4848 use g1t_kit::wire;
4949 use serde_json::{Value, json};
5050 use worker::{Context, Env, Method, Request, Response, Result, event};
9393 }
9494 }
9595
96+/// What a person whose account has not confirmed its email address may
97+/// call: who they are, their addresses, and confirming one with the code
98+/// from the email. Nothing over MCP.
99+fn pending_may(method: &str, path: &str, on_mcp: bool) -> bool {
100+ !on_mcp
101+ && matches!(
102+ (method, path.trim_end_matches('/')),
103+ ("GET", "/user") | ("GET", "/user/emails") | ("POST", "/user/emails/confirm")
104+ )
105+}
106+
96107 /// An error in the shape every endpoint uses.
97108 fn failure(failure: &Failure) -> Result<Response> {
98109 Ok(reply(&json!({ "error": failure }))?.with_status(failure.code.http_status()))
613624 Ok(viewer) => viewer,
614625 Err(refused) => return Ok(refused),
615626 };
627+ // A person who has not confirmed their email address: who they are,
628+ // their addresses, and confirming one, nothing else (REST or MCP).
629+ if viewer.as_ref().is_some_and(User::awaits_confirmation) && !pending_may(method, &path, on_mcp) {
630+ return fail(
631+ FailureCode::Forbidden,
632+ &g1t_contracts::accounts::confirm_email_first(&services.addresses.site),
633+ );
634+ }
616635 services.audit = audit::AuditContext::of(&request, on_mcp);
617636 // An agent's token: what it may do comes with it, on the composite
618637 // identity identity resolved it to.
854873 use serde_json::json;
855874
856875 #[test]
876+ fn an_unconfirmed_account_may_only_see_itself_and_confirm_its_address() {
877+ assert!(super::pending_may("GET", "/user", false));
878+ assert!(super::pending_may("GET", "/user/emails/", false));
879+ assert!(super::pending_may("POST", "/user/emails/confirm", false));
880+ assert!(!super::pending_may("POST", "/user/emails", false));
881+ assert!(!super::pending_may("POST", "/workspaces", false));
882+ assert!(!super::pending_may("GET", "/repos/acme/rocket", false));
883+ assert!(!super::pending_may("POST", "/user/emails/confirm", true));
884+ assert!(!super::pending_may("POST", "/", true));
885+ }
886+
887+ #[test]
857888 fn a_job_spec_gets_the_toolkits_variables() {
858889 // As the actions service sends it, converted as the API does.
859890 let sent = json!({ "variables": { "GITHUB_SHA": "abc" }, "runtime": { "token": "h.p.s", "idToken": true } });
+2−1
2626 (
2727 "Accounts",
2828 "Signing in from a tool, who a token acts as, and your email addresses.",
29− &[Op::Whoami, Op::ListEmails, Op::AddEmail, Op::RemoveEmail, Op::UpdateEmailSettings],
29+ &[Op::Whoami, Op::ListEmails, Op::AddEmail, Op::ConfirmEmail, Op::RemoveEmail, Op::UpdateEmailSettings],
3030 ),
3131 (
3232 "Notifications",
535535 Op::LeaveWorkspace => "Leave a workspace",
536536 Op::ListEmails => "List your email addresses",
537537 Op::AddEmail => "Add an email address",
538+ Op::ConfirmEmail => "Confirm an email address",
538539 Op::RemoveEmail => "Remove an email address",
539540 Op::UpdateEmailSettings => "Change your email settings",
540541 Op::ListInvites => "List your invites",
+25−1
114114 LeaveWorkspace,
115115 ListEmails,
116116 AddEmail,
117+ ConfirmEmail,
117118 RemoveEmail,
118119 UpdateEmailSettings,
119120 ListInvites,
671672 }
672673
673674 impl Op {
674− pub const ALL: [Op; 308] = [
675+ pub const ALL: [Op; 309] = [
675676 Op::Whoami,
676677 Op::GetWorkspace,
677678 Op::CreateWorkspace,
684685 Op::LeaveWorkspace,
685686 Op::ListEmails,
686687 Op::AddEmail,
688+ Op::ConfirmEmail,
687689 Op::RemoveEmail,
688690 Op::UpdateEmailSettings,
689691 Op::ListInvites,
10011003 Op::LeaveWorkspace => "leave_workspace",
10021004 Op::ListEmails => "list_emails",
10031005 Op::AddEmail => "add_email",
1006+ Op::ConfirmEmail => "confirm_email",
10041007 Op::RemoveEmail => "remove_email",
10051008 Op::UpdateEmailSettings => "update_email_settings",
10061009 Op::ListInvites => "list_invites",
12001203 Op::AddEmail => {
12011204 "Add an email address to your account. g1t emails it a link to confirm it; until then it cannot be primary and does not sign you in. Adding an address you added before and have not confirmed sends the link again. An address another account has confirmed cannot be added. An account has at most 10. Needs your account `password`; your confirmed addresses are told. People only."
12021205 }
1206+ Op::ConfirmEmail => {
1207+ "Confirm an email address with the six-digit `code` from the confirmation email g1t sent it. The same email has a link that does the same; either one works, once, for 60 minutes, and asking for a new email ends both. A new account must confirm its address before it can do anything else: until then this, `GET /user` and `GET /user/emails` are the only calls its token can make, and everything else, MCP included, is refused with `403`. Confirming a new account's address also joins the workspace its invite named, when the invite still applies: the answer's `joined` names it, or `invite_lapsed` says why not. Ten wrong codes in an hour pause checking for the account. People only."
1208+ }
12031209 Op::RemoveEmail => {
12041210 "Remove an email address from your account. Never your primary address (make another primary first) and never your last confirmed one. Needs your account `password`; every confirmed address, the removed one included, is told. People only."
12051211 }
17441750 &[],
17451751 ),
17461752 Op::ListEmails => object(json!({}), &[]),
1753+ Op::ConfirmEmail => object(
1754+ json!({
1755+ "code": {
1756+ "type": "string",
1757+ "description": "The six-digit code from the confirmation email. Spaces and hyphens are ignored.",
1758+ },
1759+ }),
1760+ &["code"],
1761+ ),
17471762 Op::AddEmail => object(
17481763 json!({
17491764 "email": { "type": "string", "description": "The address to add." },
32393254 | Op::LeaveWorkspace
32403255 | Op::ListEmails
32413256 | Op::AddEmail
3257+ | Op::ConfirmEmail
32423258 | Op::RemoveEmail
32433259 | Op::UpdateEmailSettings
32443260 | Op::ListInvites
35143530 // A person's addresses: identity refuses anyone but a person, and
35153531 // the password is the proof a sensitive change needs.
35163532 Op::ListEmails => pass(identity, "list_emails", &json!({ "user": actor() })).await,
3533+ Op::ConfirmEmail => {
3534+ pass(
3535+ identity,
3536+ "confirm_email_code",
3537+ &g1t_contracts::accounts::ConfirmEmailCodeArgs { user: actor(), code: text(input, "code"), client: None },
3538+ )
3539+ .await
3540+ }
35173541 Op::AddEmail | Op::RemoveEmail => {
35183542 let method = if self == Op::AddEmail { "add_email" } else { "remove_email" };
35193543 pass(
+14−1
282282 "commit_email": "6c1d0efg+ada@users.noreply.g1t.sh",
283283 "limit": 10
284284 },
285− "notes": "Answers `403` with code `reauth_required` when `password` is missing or wrong, and `409` when another account has confirmed the address. The new address stays unconfirmed until its link is followed."
285+ "notes": "Answers `403` with code `reauth_required` when `password` is missing or wrong, and `409` when another account has confirmed the address. g1t emails the address a six-digit code and a link; it stays unconfirmed until the code is sent to [`confirm_email`](/reference/api/accounts/confirm-email/) or the link is followed."
286+ },
287+ "confirm_email": {
288+ "request": {
289+ "code": "482913"
290+ },
291+ "response": {
292+ "username": "ada",
293+ "email": "ada@example.com",
294+ "verified": true,
295+ "joined": "acme",
296+ "invite_lapsed": null
297+ },
298+ "notes": "The code confirms the address it was sent to, and ends the link sent with it. A wrong, used or expired code answers `422` with one message for all three; after ten wrong codes in an hour the account answers `409` for a while, and the link in the email still works. `verified` says whether the account is confirmed: whether its primary address is. `joined` is set when confirming a new account's address joined the workspace its invite named; when the invite was revoked or expired, or the workspace deleted, while the account waited, the address is confirmed all the same and `invite_lapsed` says so. See [Confirming your email address](/guides/authentication/#confirming-your-email-address)."
286299 },
287300 "remove_email": {
288301 "request": {
+1−0
202202 Op::ListEmails | Op::AddEmail | Op::RemoveEmail | Op::UpdateEmailSettings => {
203203 through::<g1t_contracts::accounts::AccountEmails>(op, sent)
204204 }
205+ Op::ConfirmEmail => through::<g1t_contracts::accounts::EmailConfirmed>(op, sent),
205206 Op::ListInvites => through::<g1t_contracts::identity::InvitesOverview>(op, sent),
206207 Op::CreateInvite | Op::RevokeInvite | Op::InviteMember | Op::RevokeWorkspaceInvite => {
207208 through::<g1t_contracts::identity::Invite>(op, sent)
+1−0
4444 route("DELETE", "/workspaces/:workspace", Op::DeleteWorkspace, &[]),
4545 route("GET", "/user/emails", Op::ListEmails, &[]),
4646 route("POST", "/user/emails", Op::AddEmail, &[]),
47+ route("POST", "/user/emails/confirm", Op::ConfirmEmail, &[]),
4748 route("DELETE", "/user/emails/:email", Op::RemoveEmail, &[]),
4849 route("PATCH", "/user/email-settings", Op::UpdateEmailSettings, &[]),
4950 route("GET", "/user/invites", Op::ListInvites, &[]),
+1−0
494494 a("whoami", Op::Whoami, "Who the token acts as, and its workspaces"),
495495 a("list_emails", Op::ListEmails, "Your addresses"),
496496 a("add_email", Op::AddEmail, "Add an address"),
497+ a("confirm_email", Op::ConfirmEmail, "Confirm an address with the code from its email"),
497498 a("remove_email", Op::RemoveEmail, "Remove an address"),
498499 a("update_email_settings", Op::UpdateEmailSettings, "Primary, backup and privacy"),
499500 a("list_invites", Op::ListInvites, "Your invites to g1t"),
+104−22
1111 on the same page. Usernames are lowercase letters, digits and single
1212 hyphens, up to 39 characters.
1313
14+Before you can do anything else, you [confirm your email
15+address](#confirming-your-email-address) with the code g1t emails you.
16+
1417 Accounts can only be created in a browser. There is no API for it, by
1518 design: it keeps passwords out of scripts and agents, and lets g1t protect
1619 the one place accounts are made.
115118 - works once, for one new account;
116119 - works for 30 days;
117120 - when it was made for an email address, works only with that address;
118−- can be revoked by whoever made it until it is used.
121+- can be revoked by whoever made it until it is used, and after that until
122+ the new account [confirms its email address](#confirming-your-email-address).
119123
120124 ### Using an invite
121125
124128 repository, or just making an account), and finishes the job there:
125129
126130 1. **No account yet**: sign up on the page. When the invite was sent to
127− your address, the email field is filled in and locked, and the address
128− is confirmed already, so no confirmation email follows. Choose a
129− username (one is suggested from your address) and a password, or select
131+ your address, the email field is filled in and locked. Choose a
132+ username (one is suggested from your address) and a password, then
133+ [confirm the address](#confirming-your-email-address) with the code g1t
134+ emails it, even though the invite came there: an invite link can be
135+ forwarded, so it does not prove the inbox is yours. Or select
130136 **Continue with GitHub**: the invite rides along, and the account uses
131− the invited address when GitHub has verified it too.
137+ the invited address when GitHub has verified it too, in which case no
138+ confirmation is needed.
132139 2. **The address already has an account**: select **Sign in to accept**.
133140 After you sign in, the invite is accepted for you.
134141 3. **Signed in as someone else**: an invite sent to one address works only
135142 for an account that has confirmed that address. The page says so and
136143 offers **Sign out and continue**.
137144
138−Once the account exists or you have signed in, you land in the workspace
139−(or the repository) the invite was for, already a member, with a one-time
140−"You're in" banner, and it becomes the workspace your sidebar shows. A
145+Once you have signed in, or your new account has confirmed its address,
146+you land in the workspace (or the repository) the invite was for, already
147+a member, with a one-time "You're in" banner, and it becomes the workspace
148+your sidebar shows.
149+
150+Signing up spends the invite at once, so nobody else can use it while you
151+confirm your address, but you join its workspace only when you confirm, in
152+the same step. Until then the invite shows as **confirming their email** to
153+whoever made it, and they can still revoke it. If the invite is revoked or
154+expires, or its workspace is deleted, before you confirm, your address is
155+confirmed all the same and g1t tells you the invite no longer applies: ask
156+whoever invited you to add you again. A
141157 code typed at [g1t.sh/register](https://g1t.sh/register) goes to the
142158 same page.
143159
155171 Each person can have **5** invites out at a time. Pending and used invites
156172 count; an invite you revoke, or one that expires before anyone uses it,
157173 comes back to you. The list under the form shows each invite's state:
158−pending, joined (with the username of who joined), expired or revoked. You
174+pending, confirming their email (used to sign up by someone who has not
175+confirmed their address yet), joined (with the username of who joined),
176+expired or revoked. You
159177 must confirm your email before you can make invites. An agent's token and
160178 a workspace's token cannot make them.
161179
163181
164182 An owner can invite an email address straight into a workspace from its
165183 People page; see [members and roles](/guides/workspaces/#members-and-roles).
166−When the address has no g1t account, accepting makes the account and joins
167−the workspace in one step, and it uses one invite. Inviting someone who is
184+When the address has no g1t account, the invite makes the account, which
185+joins the workspace once it confirms its email address, and it uses one
186+invite. Inviting someone who is
168187 already on g1t costs nothing.
169188
170189 ### Need more invites?
193212 | --- | --- | --- |
194213 | [`GET /user/invites`](/reference/api/invites/list-invites/) | `account` `list_invites` | Your invites and how many you have left |
195214 | [`POST /user/invites`](/reference/api/invites/create-invite/) | `account` `create_invite` | Make an invite, optionally for one `email` |
196−| [`DELETE /user/invites/{id}`](/reference/api/invites/revoke-invite/) | `account` `revoke_invite` | Revoke a pending invite |
215+| [`DELETE /user/invites/{id}`](/reference/api/invites/revoke-invite/) | `account` `revoke_invite` | Revoke a pending invite, or one whose new account has not confirmed its address |
197216 | [`POST /workspaces/{workspace}/invitations`](/reference/api/invites/invite-member/) | `workspace` `invite_member` | Invite an address into a workspace. Owners only. |
198217
199−## Confirming your email
218+## Confirming your email address
219+
220+A new account confirms its email address before it can do anything else on
221+g1t. Right after you sign up, g1t emails the address from `noreply@g1t.sh`
222+with two ways to confirm it, either one enough:
223+
224+- a **six-digit code**, shown large in the email (and in its subject, so a
225+ phone's notification shows it). Type it on the **Confirm your email**
226+ page g1t takes you to. On a phone, the keyboard offers it from the
227+ message.
228+- a **link**, for when you would rather click than type. It works whether
229+ or not you are signed in, in any browser.
230+
231+The code and the link work for **60 minutes**, once. Using either ends the
232+other. **Send a new code** on the confirmation page sends a fresh code and
233+link, at most once a minute and 10 times an hour, and the ones before stop
234+working.
235+
236+| On the confirmation page | What it does |
237+| --- | --- |
238+| **Confirm email** | Checks the code. A wrong, used or expired code gets the same answer. After 10 wrong codes in an hour, codes for the account are not checked for a while (a minute, then longer); the link in the email still works. Wrong codes from one network are limited the same way. |
239+| **Send a new code** | A new code and link; the ones before stop working. |
240+| **Wrong address? Change it** | Replaces the address you signed up with and sends the new one a code. Only while the account has no confirmed address. |
241+| **Sign out** | Signs out. Sign in again to come back to the page. |
242+
243+### Until you confirm
244+
245+An account that has not confirmed its address can only confirm it:
246+
247+- **The site** sends every page to the confirmation page, and back to where
248+ you were going once you confirm. Signing in and out, password resets,
249+ the confirmation link, and g1t's [policies](https://g1t.sh/policies),
250+ security, support, status and pricing pages stay open.
251+- **The API** answers `403` with a message saying to confirm your address,
252+ except for [`GET /user`](/reference/api/accounts/whoami/),
253+ [`GET /user/emails`](/reference/api/accounts/list-emails/) and
254+ [`POST /user/emails/confirm`](/reference/api/accounts/confirm-email/).
255+- **The MCP server** answers `403` with the same message.
256+- **Git** over HTTPS refuses pushes and fetches with your credentials, with
257+ the same message. Package registries treat them as wrong credentials.
258+- You cannot create a workspace, join the one your invite named, make
259+ invites or tokens, or approve a tool's sign-in.
200260
201−g1t sends a confirmation link from `noreply@g1t.sh`. It works for 24 hours.
261+You cannot make a token before you confirm, so the API and MCP refusals
262+matter only for an account that made one before this rule existed.
202263
203−Until you follow it you can sign in and look around, but you cannot create
204−repositories, push, or open issues and pull requests. Those requests fail with `403` and a
205−message telling you to confirm your address. To get a new link, sign in and
206−use the banner at the top of the site.
264+### Addresses GitHub has confirmed
207265
266+An account made with **Continue with GitHub** starts confirmed: its address
267+is one GitHub has verified, so GitHub has already proved the inbox is
268+yours, and no code is sent.
269+
270+### Accounts that never confirmed
271+
272+Accounts are confirmed once and stay confirmed. An account made before this
273+rule that never confirmed its address, or whose address another account
274+confirmed first, is held at the confirmation page the same way the next
275+time it signs in; **Send a new code** gets it a code, and **Change it**
276+gives it a new address.
277+
278+### Confirming through the API
279+
280+| Route | MCP tool and action | What it does |
281+| --- | --- | --- |
282+| [`POST /user/emails/confirm`](/reference/api/accounts/confirm-email/) | `account` `confirm_email` | Confirm an address with the `code` from its email |
283+
284+The answer says whether the account is now confirmed (`verified`), the
285+workspace confirming joined it to (`joined`), or why its invite no longer
286+applies (`invite_lapsed`).
287+
208288 ## Email addresses
209289
210290 An account can have up to 10 email addresses. Manage them in
215295 | Primary | Get account mail and password reset links. Exactly one, always confirmed once any address is. |
216296 | Confirmed | Sign you in (type it instead of your username), ask for a password reset, and mark commits that carry it as yours. |
217297 | Backup | Get security notices as well as the primary. Optional, and a confirmed address other than the primary. |
218−| Unconfirmed | Nothing yet. It is not yours until you follow the link g1t sent it. |
298+| Unconfirmed | Nothing yet. It is not yours until you enter the code or follow the link g1t sent it. |
219299
220300 A confirmed address belongs to one account. Anyone can add an address they
221−have not confirmed; the first account to follow its link keeps it, and the
301+have not confirmed; the first account to confirm it keeps it, and the
222302 address leaves every other account that added it. An address another
223303 account has confirmed cannot be added.
224304
226306
227307 1. Open [Settings → Emails](https://g1t.sh/settings/emails).
228308 2. Enter the address under **Add an email address** and select **Add**.
229−3. Follow the link g1t sends it. The link works for 24 hours; **Resend
230− link** sends a new one, at most once a minute and 10 times an hour.
309+3. Enter the code g1t emails it, or follow the link in the same email.
310+ Both work for 60 minutes; **Resend link** sends a new code and link, at
311+ most once a minute and 10 times an hour, and ends the ones before.
231312
232313 If your account had no confirmed address yet, the first one you confirm
233314 becomes your primary.
312393 | --- | --- | --- |
313394 | [`GET /user/emails`](/reference/api/accounts/list-emails/) | `account` `list_emails` | Your addresses and email settings |
314395 | [`POST /user/emails`](/reference/api/accounts/add-email/) | `account` `add_email` | Add an address; takes `email` and `password` |
396+| [`POST /user/emails/confirm`](/reference/api/accounts/confirm-email/) | `account` `confirm_email` | Confirm an address with the `code` from its email |
315397 | [`DELETE /user/emails/{email}`](/reference/api/accounts/remove-email/) | `account` `remove_email` | Remove an address; takes `password` |
316398 | [`PATCH /user/email-settings`](/reference/api/accounts/update-email-settings/) | `account` `update_email_settings` | Change `primary`, `backup`, `private_email` or `block_private_pushes` |
317399
+3−1
6262 3. Open [http://localhost:8787](http://localhost:8787) and create an
6363 account.
6464 4. Open the Mailpit inbox at [http://localhost:8025](http://localhost:8025)
65− and follow the link in the confirmation email.
65+ and enter the code from the confirmation email on the page the site
66+ shows you, or follow the link in the same email. Set `IDENTITY_KEY`
67+ (the setup does) so codes are kept as keyed hashes.
6668 5. Create a workspace, then a repository.
6769
6870 ## Push and clone
+6−3
332332 never says which it was.
333333
334334 The email names you and the workspace and links to the invite's page.
335−Someone new signs up right there, with the invited address filled in and
336−already confirmed; someone with an account signs in. Either way they land
337−in the workspace as a member, with a one-time welcome. See
335+Someone new signs up right there, with the invited address filled in, and
336+joins once they confirm it with the code g1t emails them; someone with an
337+account signs in. Either way they land in the workspace as a member, with
338+a one-time welcome. Until a new account confirms its address, its invite
339+shows as **confirming their email** under the members, and you can still
340+revoke it. See
338341 [using an invite](/guides/authentication/#using-an-invite).
339342
340343 Pending invites are listed under the members, with a link to copy and
+1−1
2323
2424 [Sign up](https://g1t.sh/register) with your invite (g1t is invite-only
2525 for now; see [invites](/guides/authentication/#invites)) and confirm your
26− email. Then create a
26+ email with the code g1t sends it (or the link in the same email). Then create a
2727 **workspace**: it owns repositories and is the first part of their
2828 address, `g1t.sh/<workspace>/<repo>`. See [workspaces](/guides/workspaces/).
2929
+5−2
729729 invites while g1t is [invite-only](/guides/authentication/#invites), and
730730 invitations to repositories waiting for you. `whoami` is the default
731731 action, and needs no scope. An agent's token and a workspace's token cannot
732−use the email and invite actions.
732+use the email and invite actions. An account that has not confirmed its
733+email address gets `403` from every tool until it does; see
734+[until you confirm](/guides/authentication/#until-you-confirm).
733735
734736 | Action | What it does | Required | Scope |
735737 | --- | --- | --- | --- |
736738 | [`whoami`](/reference/api/accounts/whoami/) | Who the access token acts as, and the workspaces it can work in. `kind` is `user`, `workspace` or `agent`. | None | None |
737739 | [`list_emails`](/reference/api/accounts/list-emails/) | Your email addresses and email settings. People only. | None | `account:read` |
738−| [`add_email`](/reference/api/accounts/add-email/) | Add an address; g1t emails it a link to confirm it. | `email`, `password` | `account:write` |
740+| [`add_email`](/reference/api/accounts/add-email/) | Add an address; g1t emails it a code and a link to confirm it. | `email`, `password` | `account:write` |
741+| [`confirm_email`](/reference/api/accounts/confirm-email/) | Confirm an address with the six-digit code from its email. See [confirming your email address](/guides/authentication/#confirming-your-email-address). | `code` | `account:write` |
739742 | [`remove_email`](/reference/api/accounts/remove-email/) | Remove an address; never the primary or the last confirmed one. | `email`, `password` | `account:write` |
740743 | [`update_email_settings`](/reference/api/accounts/update-email-settings/) | Change `primary` or `backup` (with `password`), `private_email` or `block_private_pushes`. See [email addresses](/guides/authentication/#email-addresses). | None | `account:write` |
741744 | [`list_invites`](/reference/api/invites/list-invites/) | Your invites, newest first, and how many you have left. | None | `account:read` |
+0−0

Binary or large file; its contents are not shown.

+2−0
137137 switch (invite.status) {
138138 case "pending":
139139 return <Badge tone="lavender">Pending</Badge>;
140+ case "awaiting_confirmation":
141+ return <Badge tone="lavender">Used{invite.redeemedBy ? ` by ${invite.redeemedBy}` : ""}, email not confirmed</Badge>;
140142 case "redeemed":
141143 return <Badge tone="mint">Used{invite.redeemedBy ? ` by ${invite.redeemedBy}` : ""}</Badge>;
142144 case "expired":
+1−1
1919 <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
2020 <span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs ${TONE[state.tone]}`}>{state.label}</span>
2121 <span className="min-w-0 truncate text-sm">{inviteFor(invite)}</span>
22− {invite.status === "pending" && (
22+ {(invite.status === "pending" || invite.status === "awaiting_confirmation") && (
2323 <Form method="post" className="ml-auto">
2424 <input type="hidden" name="intent" value="revoke-invite" />
2525 <input type="hidden" name="id" value={invite.id} />
+78−0
1+import assert from "node:assert/strict";
2+import { readFileSync } from "node:fs";
3+import { test } from "node:test";
4+
5+import { CONFIRM_PATH, afterConfirming, confirmGate, confirmedLine, openWhilePending, pageOf } from "./confirm-gate.ts";
6+
7+const pending = { verified: false };
8+const confirmed = { verified: true };
9+
10+test("a pending account is sent to confirm its address from every app page", () => {
11+ assert.equal(confirmGate("/", "", pending), CONFIRM_PATH);
12+ assert.equal(confirmGate("/acme", "", pending), `${CONFIRM_PATH}?next=%2Facme`);
13+ assert.equal(confirmGate("/workspaces/new", "?next=/x", pending), `${CONFIRM_PATH}?next=%2Fworkspaces%2Fnew%3Fnext%3D%252Fx`);
14+ assert.equal(confirmGate("/settings/tokens", "", pending), `${CONFIRM_PATH}?next=%2Fsettings%2Ftokens`);
15+ assert.equal(confirmGate("/invite/g1t-abcd", "", pending), `${CONFIRM_PATH}?next=%2Finvite%2Fg1t-abcd`);
16+ assert.equal(confirmGate("/acme/rocket/pulls", "?state=open", pending), `${CONFIRM_PATH}?next=%2Facme%2Frocket%2Fpulls%3Fstate%3Dopen`);
17+});
18+
19+test("client navigations are gated as the page they load", () => {
20+ assert.equal(pageOf("/acme.data"), "/acme");
21+ assert.equal(pageOf("/_root.data"), "/");
22+ assert.equal(confirmGate("/acme/rocket.data", "?_routes=routes%2Frepo%2Flayout", pending), `${CONFIRM_PATH}?next=%2Facme%2Frocket`);
23+ assert.equal(confirmGate("/_root.data", "", pending), CONFIRM_PATH);
24+ assert.equal(confirmGate("/confirm-email.data", "", pending), null);
25+});
26+
27+test("the pages confirming needs, and the public pages about g1t, stay open", () => {
28+ for (const path of [
29+ "/confirm-email",
30+ "/verify",
31+ "/logout",
32+ "/login",
33+ "/login/two-factor",
34+ "/register",
35+ "/forgot",
36+ "/reset",
37+ "/policies",
38+ "/policies/terms",
39+ "/security",
40+ "/support",
41+ "/status",
42+ "/pricing",
43+ "/auth/github/callback",
44+ "/.well-known/security.txt",
45+ ]) {
46+ assert.equal(openWhilePending(path), true, path);
47+ assert.equal(confirmGate(path, "", pending), null, path);
48+ }
49+ for (const path of ["/", "/explore", "/search", "/settings", "/settings/emails", "/device", "/oauth/authorize", "/new", "/inbox"]) {
50+ assert.equal(openWhilePending(path), false, path);
51+ }
52+});
53+
54+test("confirmed accounts, visitors and non-people are never gated", () => {
55+ assert.equal(confirmGate("/acme", "", confirmed), null);
56+ assert.equal(confirmGate("/acme", "", null), null);
57+ assert.equal(confirmGate("/acme", "", { kind: "workspace", verified: false }), null);
58+ assert.equal(confirmGate("/acme", "", { kind: "user" }), `${CONFIRM_PATH}?next=%2Facme`);
59+});
60+
61+test("what the page says once confirmed, and where it goes", () => {
62+ assert.equal(confirmedLine({}), "Your email address is confirmed.");
63+ assert.equal(confirmedLine({ joined: "acme" }), "Your email address is confirmed, and you have joined acme.");
64+ assert.match(confirmedLine({ inviteLapsed: "Your email address is confirmed. The invite … was revoked" }), /revoked/);
65+ assert.equal(afterConfirming("/", "acme"), "/acme");
66+ assert.equal(afterConfirming("/acme/rocket", "acme"), "/acme/rocket");
67+ assert.equal(afterConfirming("", null), "/");
68+});
69+
70+test("the code field is a one-time code typed with a number pad", () => {
71+ const page = readFileSync(new URL("../routes/confirm-email.tsx", import.meta.url), "utf8");
72+ const input = /<Input[\s\S]*?name="code"[\s\S]*?\/>/.exec(page)?.[0] ?? "";
73+ assert.match(input, /autoComplete="one-time-code"/);
74+ assert.match(input, /inputMode="numeric"/);
75+ assert.match(input, /pattern="\[0-9 -\]\*"/);
76+ // Either route: the page says the link in the email works too.
77+ assert.match(page, /link in the email/);
78+});
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.