1
0
forked from wrenn/wrenn

feat: separate GitHub OAuth login/signup flows with name confirmation

Block auto-account creation when signing in via GitHub from login mode.
Signup via GitHub now shows a name confirmation dialog before redirecting
to dashboard, letting users verify/edit their display name pulled from
GitHub.

- Add intent query param to OAuth redirect, persisted in HMAC-signed state cookie
- Block registration in callback when intent=login, return no_account error
- Set wrenn_oauth_new_signup cookie on new account creation
- Frontend callback shows name confirmation dialog for new signups
- Add no_account error message to login page
This commit is contained in:
2026-04-21 11:03:12 +06:00
parent dbc6030c17
commit 6a6b489471
3 changed files with 178 additions and 20 deletions

View File

@ -1,12 +1,18 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { onMount } from 'svelte';
import { auth } from '$lib/auth.svelte'; import { auth } from '$lib/auth.svelte';
import { teams } from '$lib/teams.svelte'; import { teams } from '$lib/teams.svelte';
import { updateName } from '$lib/api/me';
import { IconUser, IconMail } from '$lib/components/icons';
// Check for error in URL params (errors are still passed via query params). let showConfirmDialog = $state(false);
const params = $page.url.searchParams; let confirmName = $state('');
const error = params.get('error'); let confirmEmail = $state('');
let saving = $state(false);
let nameError = $state('');
let pendingAuth: { token: string; user_id: string; team_id: string; email: string; name: string } | null = null;
function getCookie(name: string): string | null { function getCookie(name: string): string | null {
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
@ -19,33 +25,157 @@
'wrenn_oauth_user_id', 'wrenn_oauth_user_id',
'wrenn_oauth_team_id', 'wrenn_oauth_team_id',
'wrenn_oauth_email', 'wrenn_oauth_email',
'wrenn_oauth_name' 'wrenn_oauth_name',
'wrenn_oauth_new_signup'
]) { ]) {
document.cookie = `${name}=; path=/auth/; max-age=0`; document.cookie = `${name}=; path=/auth/; max-age=0`;
} }
} }
function finishLogin() {
if (!pendingAuth) return;
teams.reset();
auth.login(pendingAuth);
goto('/dashboard');
}
async function handleConfirm() {
if (!pendingAuth) return;
saving = true;
nameError = '';
// Update name if user changed it.
if (confirmName.trim() && confirmName.trim() !== pendingAuth.name) {
// Log in first so the PATCH /v1/me request is authenticated.
teams.reset();
auth.login(pendingAuth);
const result = await updateName(confirmName.trim());
if (result.ok) {
// updateName returns refreshed auth data — re-login with updated info.
auth.login(result.data);
goto('/dashboard');
} else {
nameError = result.error;
saving = false;
}
} else {
finishLogin();
}
}
onMount(() => {
const params = $page.url.searchParams;
const error = params.get('error');
if (error) { if (error) {
goto(`/login?error=${encodeURIComponent(error)}`); goto(`/login?error=${encodeURIComponent(error)}`);
} else { return;
}
const token = getCookie('wrenn_oauth_token'); const token = getCookie('wrenn_oauth_token');
const userId = getCookie('wrenn_oauth_user_id'); const userId = getCookie('wrenn_oauth_user_id');
const teamId = getCookie('wrenn_oauth_team_id'); const teamId = getCookie('wrenn_oauth_team_id');
const email = getCookie('wrenn_oauth_email'); const email = getCookie('wrenn_oauth_email');
const name = getCookie('wrenn_oauth_name') ?? ''; const name = getCookie('wrenn_oauth_name') ?? '';
const isNewSignup = getCookie('wrenn_oauth_new_signup') === '1';
clearOAuthCookies(); clearOAuthCookies();
if (token && userId && teamId && email) { if (token && userId && teamId && email) {
teams.reset(); pendingAuth = { token, user_id: userId, team_id: teamId, email, name };
auth.login({ token, user_id: userId, team_id: teamId, email, name });
goto('/dashboard'); if (isNewSignup) {
confirmName = name;
confirmEmail = email;
showConfirmDialog = true;
} else {
finishLogin();
}
} else { } else {
goto('/login?error=missing_token'); goto('/login?error=missing_token');
} }
} });
</script> </script>
{#if showConfirmDialog}
<div class="flex min-h-screen items-center justify-center bg-[var(--color-bg-0)]">
<div
class="w-full max-w-[420px] rounded-[var(--radius-card)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)]"
style="animation: fadeUp 0.2s ease both; box-shadow: var(--shadow-dialog)"
>
<div class="p-6">
<h2 class="font-serif text-heading text-[var(--color-text-bright)]">Almost there</h2>
<p class="mt-1.5 text-ui text-[var(--color-text-secondary)]">
We pulled your details from GitHub. Change your display name if you'd like.
</p>
<div class="mt-5 space-y-3">
<!-- Name (editable) -->
<div>
<label for="confirm-name" class="mb-1.5 block text-label uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">
Display name
</label>
<div class="group relative">
<div class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-colors duration-150 group-focus-within:text-[var(--color-accent)]">
<IconUser size={14} />
</div>
<input
id="confirm-name"
type="text"
bind:value={confirmName}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-2)] py-3 pl-9 pr-3 text-body text-[var(--color-text-bright)] outline-none transition-all duration-150 placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-accent)]"
/>
</div>
</div>
<!-- Email (read-only) -->
<div>
<label for="confirm-email" class="mb-1.5 block text-label uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]">
Email
</label>
<div class="group relative">
<div class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]">
<IconMail size={14} />
</div>
<input
id="confirm-email"
type="email"
value={confirmEmail}
disabled
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-3)] py-3 pl-9 pr-3 text-body text-[var(--color-text-secondary)] outline-none cursor-not-allowed pointer-events-none"
/>
</div>
</div>
</div>
{#if nameError}
<p class="mt-3 text-ui text-[var(--color-red)]">{nameError}</p>
{/if}
<!-- Actions -->
<div class="mt-6 flex justify-end">
<button
type="button"
onclick={handleConfirm}
disabled={saving || !confirmName.trim()}
class="rounded-[var(--radius-button)] bg-[var(--color-accent)] px-5 py-2.5 text-body font-semibold text-white transition-all duration-150 hover:brightness-115 hover:-translate-y-px active:translate-y-0 disabled:pointer-events-none disabled:opacity-50"
>
{#if saving}
<span class="inline-flex items-center gap-2">
<span class="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-white/30 border-t-white"></span>
Setting up…
</span>
{:else}
Get started
{/if}
</button>
</div>
</div>
</div>
</div>
{:else}
<div class="flex min-h-screen items-center justify-center"> <div class="flex min-h-screen items-center justify-center">
<p class="text-ui text-[var(--color-text-secondary)]">Signing you in...</p> <p class="text-ui text-[var(--color-text-secondary)]">Signing you in...</p>
</div> </div>
{/if}

View File

@ -29,6 +29,7 @@
access_denied: 'Access was denied by the provider', access_denied: 'Access was denied by the provider',
email_taken: 'An account with this email already exists', email_taken: 'An account with this email already exists',
exchange_failed: 'Authentication failed — please try again', exchange_failed: 'Authentication failed — please try again',
no_account: 'No GitHub account connected — sign up instead',
}; };
// Read OAuth error forwarded from /auth/github/callback // Read OAuth error forwarded from /auth/github/callback
@ -259,7 +260,7 @@
<!-- GitHub OAuth --> <!-- GitHub OAuth -->
<a <a
href="/api/auth/oauth/github" href="/api/auth/oauth/github?intent={mode === 'signin' ? 'login' : 'signup'}"
class="flex w-full items-center justify-center gap-2.5 rounded-[var(--radius-button)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] px-4 py-3 text-body font-medium text-[var(--color-text-bright)] no-underline transition-all duration-150 hover:border-[var(--color-accent)] hover:text-[var(--color-text-bright)]" class="flex w-full items-center justify-center gap-2.5 rounded-[var(--radius-button)] border border-[var(--color-border-mid)] bg-[var(--color-bg-2)] px-4 py-3 text-body font-medium text-[var(--color-text-bright)] no-underline transition-all duration-150 hover:border-[var(--color-accent)] hover:text-[var(--color-text-bright)]"
> >
<IconGithub size={16} /> <IconGithub size={16} />

View File

@ -55,8 +55,14 @@ func (h *oauthHandler) Redirect(w http.ResponseWriter, r *http.Request) {
return return
} }
mac := computeHMAC(h.jwtSecret, state) // Persist intent (login|signup) in the state cookie so the callback can enforce it.
cookieVal := state + ":" + mac intent := r.URL.Query().Get("intent")
if intent != "signup" {
intent = "login"
}
mac := computeHMAC(h.jwtSecret, state+":"+intent)
cookieVal := state + ":" + mac + ":" + intent
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "oauth_state", Name: "oauth_state",
@ -105,13 +111,17 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
Secure: isSecure(r), Secure: isSecure(r),
}) })
parts := strings.SplitN(stateCookie.Value, ":", 2) parts := strings.SplitN(stateCookie.Value, ":", 3)
if len(parts) != 2 { if len(parts) < 2 {
redirectWithError(w, r, redirectBase, "invalid_state") redirectWithError(w, r, redirectBase, "invalid_state")
return return
} }
nonce, expectedMAC := parts[0], parts[1] nonce, expectedMAC := parts[0], parts[1]
if !hmac.Equal([]byte(computeHMAC(h.jwtSecret, nonce)), []byte(expectedMAC)) { intent := "login"
if len(parts) == 3 && parts[2] == "signup" {
intent = "signup"
}
if !hmac.Equal([]byte(computeHMAC(h.jwtSecret, nonce+":"+intent)), []byte(expectedMAC)) {
redirectWithError(w, r, redirectBase, "invalid_state") redirectWithError(w, r, redirectBase, "invalid_state")
return return
} }
@ -249,6 +259,12 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
return return
} }
// Block auto-registration when intent is login-only.
if intent == "login" {
redirectWithError(w, r, redirectBase, "no_account")
return
}
// New OAuth identity — check for email collision. // New OAuth identity — check for email collision.
existingUser, err := h.db.GetUserByEmail(ctx, email) existingUser, err := h.db.GetUserByEmail(ctx, email)
if err == nil { if err == nil {
@ -365,6 +381,17 @@ func (h *oauthHandler) Callback(w http.ResponseWriter, r *http.Request) {
return return
} }
// Signal frontend that this is a new signup so it can show the name confirmation dialog.
http.SetCookie(w, &http.Cookie{
Name: "wrenn_oauth_new_signup",
Value: "1",
Path: "/auth/",
MaxAge: 60,
HttpOnly: false,
SameSite: http.SameSiteLaxMode,
Secure: isSecure(r),
})
redirectWithToken(w, r, redirectBase, token, id.FormatUserID(userID), id.FormatTeamID(teamID), email, profile.Name) redirectWithToken(w, r, redirectBase, token, id.FormatUserID(userID), id.FormatTeamID(teamID), email, profile.Name)
} }