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:
@ -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`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
function finishLogin() {
|
||||||
goto(`/login?error=${encodeURIComponent(error)}`);
|
if (!pendingAuth) return;
|
||||||
} else {
|
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) {
|
||||||
|
goto(`/login?error=${encodeURIComponent(error)}`);
|
||||||
|
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>
|
||||||
|
|
||||||
<div class="flex min-h-screen items-center justify-center">
|
{#if showConfirmDialog}
|
||||||
<p class="text-ui text-[var(--color-text-secondary)]">Signing you in...</p>
|
<div class="flex min-h-screen items-center justify-center bg-[var(--color-bg-0)]">
|
||||||
</div>
|
<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">
|
||||||
|
<p class="text-ui text-[var(--color-text-secondary)]">Signing you in...</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user