Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 4 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,13 @@ AUTH_GOOGLE_SECRET=
# AUTH_MICROSOFT_ENTRA_ID_SECRET=
# AUTH_MICROSOFT_ENTRA_ID_ISSUER=https://login.microsoftonline.com/common/v2.0

# Facebook sign-in (deferred to #285). Console: https://developers.facebook.com/
# Facebook sign-in (#285). Console: https://developers.facebook.com/
# Privacy Policy URL in app settings: https://www.still-point.me/privacy
# Authorized redirect URIs:
# https://www.still-point.me/api/auth/callback/facebook
# http://localhost:3000/api/auth/callback/facebook
# AUTH_FACEBOOK_ID=
# AUTH_FACEBOOK_SECRET=
AUTH_FACEBOOK_ID=
AUTH_FACEBOOK_SECRET=

# Apple sign-in (deferred to #286). Console: https://developer.apple.com/account/
# Apple does NOT allow localhost. Local dev requires a tunnel.
Expand Down
12 changes: 12 additions & 0 deletions src/app/privacy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,18 @@ export default function PrivacyPolicyPage() {
</a>
.
</li>
<li>
<strong style={{ color: "var(--fg)" }}>Meta (Facebook):</strong>{" "}
<a
href="https://www.facebook.com/about/privacy/"
target="_blank"
rel="noopener noreferrer"
style={{ color: "var(--accent-green-text)", textDecoration: "underline" }}
>
Meta Data Policy
</a>
.
</li>
</ul>

<h2 style={sectionTitle}>Retention and your choices</h2>
Expand Down
158 changes: 100 additions & 58 deletions src/components/AuthScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,73 @@
"use client";

import { useEffect, useState } from "react";
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
import { signIn } from "next-auth/react";

/** Current path + query as callbackUrl for OAuth. Preserves deep-link state (e.g. ?buddy=...); strips
* `error` so we do not forward /app?error=... after sign-in (redirect callback would bypass sp_token bridge). */
function buildOAuthCallbackUrl(): string {
const params = new URLSearchParams(window.location.search);
params.delete("error");
const search = params.toString();
return `${window.location.pathname}${search ? `?${search}` : ""}`;
}

const OAUTH_PROVIDER_BUTTON_STYLE: CSSProperties = {
background: "var(--surface-1)",
border: "1px solid var(--border-2)",
color: "var(--fg)",
fontFamily: "var(--font-newsreader), 'Newsreader', Georgia, serif",
fontSize: "15px",
padding: "12px 16px",
borderRadius: "30px",
cursor: "pointer",
transition: "all 0.3s",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "10px",
};

function onOAuthProviderButtonMouseEnter(e: React.MouseEvent<HTMLButtonElement>) {
e.currentTarget.style.borderColor = "var(--border-3)";
e.currentTarget.style.background = "var(--surface-2)";
}

function onOAuthProviderButtonMouseLeave(e: React.MouseEvent<HTMLButtonElement>) {
e.currentTarget.style.borderColor = "var(--border-2)";
e.currentTarget.style.background = "var(--surface-1)";
}

type OAuthProviderSignInButtonProps = {
provider: "google" | "facebook";
ariaLabel: string;
label: string;
icon: ReactNode;
};

function OAuthProviderSignInButton({
provider,
ariaLabel,
label,
icon,
}: OAuthProviderSignInButtonProps) {
return (
<button
type="button"
onClick={() => {
void signIn(provider, { callbackUrl: buildOAuthCallbackUrl() });
}}
style={OAUTH_PROVIDER_BUTTON_STYLE}
onMouseEnter={onOAuthProviderButtonMouseEnter}
onMouseLeave={onOAuthProviderButtonMouseLeave}
aria-label={ariaLabel}
>
{icon}
{label}
</button>
);
}

type AuthScreenProps = {
onLogin: (user: { id: string; email: string; username: string; isPublic: boolean; currentDay: number }) => void;
};
Expand All @@ -11,11 +76,11 @@ const OAUTH_ERROR_MESSAGES: Record<string, string> = {
oauth_session_missing: "Sign-in didn't complete. Please try again.",
oauth_user_missing: "We couldn't find your account. Please try again.",
oauth_internal_error: "Something went wrong on our end. Please try again.",
OAuthSignin: "Couldn't start Google sign-in. Please try again.",
OAuthCallback: "Google sign-in was cancelled or failed.",
OAuthSignin: "Couldn't start sign-in. Please try again.",
OAuthCallback: "Sign-in was cancelled or failed.",
OAuthCreateAccount: "Couldn't create your account. Please try again.",
AccessDenied: "Access denied. Please try again.",
Verification: "We couldn't verify your Google account.",
Verification: "We couldn't verify your account.",
Configuration: "Sign-in is temporarily unavailable.",
};

Expand Down Expand Up @@ -114,60 +179,37 @@ export function AuthScreen({ onLogin }: AuthScreenProps) {
</div>

<div style={{ width: "100%", display: "flex", flexDirection: "column", gap: "12px" }}>
<button
type="button"
onClick={() => {
// Carry the current page (path + query) as callbackUrl so any
// deep-link state (invite links, ?buddy=..., etc.) survives the
// OAuth round-trip. Strip `error` first — forwarding it back
// would land the user on /app?error=... after sign-in, which
// the redirect callback in auth-config treats as a failure
// target and bypasses the sp_token bridge.
const params = new URLSearchParams(window.location.search);
params.delete("error");
const search = params.toString();
const callbackUrl = `${window.location.pathname}${search ? `?${search}` : ""}`;
// Auth.js v5 changed the contract: GET /api/auth/signin/<provider>
// is rejected with UnknownAction. Signin requires POST + CSRF.
// The signIn() helper from next-auth/react fetches the CSRF
// token, posts the form, and navigates the browser to Google's
// authorize URL — same UX as the previous direct navigation,
// correct v5 contract.
void signIn("google", { callbackUrl });
}}
style={{
background: "var(--surface-1)",
border: "1px solid var(--border-2)",
color: "var(--fg)",
fontFamily: "var(--font-newsreader), 'Newsreader', Georgia, serif",
fontSize: "15px",
padding: "12px 16px",
borderRadius: "30px",
cursor: "pointer",
transition: "all 0.3s",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "10px",
}}
onMouseEnter={e => {
e.currentTarget.style.borderColor = "var(--border-3)";
e.currentTarget.style.background = "var(--surface-2)";
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = "var(--border-2)";
e.currentTarget.style.background = "var(--surface-1)";
}}
aria-label="Continue with Google"
>
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
<path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"/>
<path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z"/>
<path fill="#FBBC05" d="M3.964 10.706A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.706V4.962H.957A8.997 8.997 0 0 0 0 9c0 1.452.348 2.827.957 4.038l3.007-2.332z"/>
<path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.962L3.964 7.294C4.672 5.167 6.656 3.58 9 3.58z"/>
</svg>
Continue with Google
</button>
{/*
Auth.js v5: GET /api/auth/signin/<provider> is rejected (UnknownAction).
signIn() from next-auth/react POSTs with CSRF then redirects to the provider.
*/}
<OAuthProviderSignInButton
provider="google"
ariaLabel="Continue with Google"
label="Continue with Google"
icon={
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true">
<path fill="#4285F4" d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"/>
<path fill="#34A853" d="M9 18c2.43 0 4.467-.806 5.956-2.184l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z"/>
<path fill="#FBBC05" d="M3.964 10.706A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.706V4.962H.957A8.997 8.997 0 0 0 0 9c0 1.452.348 2.827.957 4.038l3.007-2.332z"/>
<path fill="#EA4335" d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.962L3.964 7.294C4.672 5.167 6.656 3.58 9 3.58z"/>
</svg>
}
/>

<OAuthProviderSignInButton
provider="facebook"
ariaLabel="Continue with Facebook"
label="Continue with Facebook"
icon={
<svg width="18" height="18" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="#1877F2"
d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"
/>
</svg>
}
/>

<p style={{
fontFamily: "var(--font-jetbrains), 'JetBrains Mono', monospace",
Expand Down
18 changes: 13 additions & 5 deletions src/lib/auth-config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import NextAuth from "next-auth";
import Facebook from "next-auth/providers/facebook";
import Google from "next-auth/providers/google";
import { db } from "@/db";
import { users, oauthAccounts } from "@/db/schema";
Expand Down Expand Up @@ -159,6 +160,11 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
clientSecret: process.env.AUTH_GOOGLE_SECRET,
authorization: { params: { scope: "openid email profile" } },
}),
Facebook({
clientId: process.env.AUTH_FACEBOOK_ID,
clientSecret: process.env.AUTH_FACEBOOK_SECRET,
authorization: { params: { scope: "email" } },
}),
],
// Only override `error`. Setting `pages.signIn` to a custom path tells
// Auth.js v5 that we render our own signin UI on that path, which
Expand Down Expand Up @@ -187,12 +193,14 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
// - Google: granting the `email` scope returns a verified address
// by Google's own contract; presence of `profile.email` here is
// the verification signal.
// - Other providers (Microsoft / Facebook / Apple, deferred):
// require an explicit `email_verified === true` claim. An
// unknown verification state must NOT be treated as verified —
// extend this allow-list deliberately as each provider lands.
// - Facebook: default Auth.js provider requests `email`; Graph
// `/me` returns the primary email for the logged-in account when
// granted (same trust pattern as Google — we require email below).
// - Other providers (Microsoft / Apple): require an explicit
// `email_verified === true` claim. Unknown verification state must
// NOT be treated as verified — extend this allow-list deliberately.
const emailVerified =
provider === "google"
provider === "google" || provider === "facebook"
? true
: (profile as { email_verified?: boolean }).email_verified === true;
if (!emailVerified) return false;
Expand Down
Loading