diff options
| author | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
| commit | 5c5b1993edd890a80870ee05607ac5f088191d4e (patch) | |
| tree | a721b76bcd49ba10826c53efc87302c7a689512f /apps/web/app/(auth) | |
| download | asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.tar.xz asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.zip | |
feat: asa.news RSS reader with developer tier, REST API, and webhooks
Full-stack RSS reader SaaS: Supabase + Next.js + Go worker.
Includes three subscription tiers (free/pro/developer), API key auth,
read-only REST API, webhook push notifications, Stripe billing with
proration, and PWA support.
Diffstat (limited to 'apps/web/app/(auth)')
| -rw-r--r-- | apps/web/app/(auth)/forgot-password/page.tsx | 101 | ||||
| -rw-r--r-- | apps/web/app/(auth)/layout.tsx | 11 | ||||
| -rw-r--r-- | apps/web/app/(auth)/reset-password/page.tsx | 120 | ||||
| -rw-r--r-- | apps/web/app/(auth)/sign-in/page.tsx | 230 | ||||
| -rw-r--r-- | apps/web/app/(auth)/sign-up/page.tsx | 115 |
5 files changed, 577 insertions, 0 deletions
diff --git a/apps/web/app/(auth)/forgot-password/page.tsx b/apps/web/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..748ba47 --- /dev/null +++ b/apps/web/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,101 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" + +export default function ForgotPasswordPage() { + const [emailAddress, setEmailAddress] = useState("") + const [errorMessage, setErrorMessage] = useState<string | null>(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [isEmailSent, setIsEmailSent] = useState(false) + + async function handleResetRequest(event: React.FormEvent) { + event.preventDefault() + setIsSubmitting(true) + setErrorMessage(null) + + const supabaseClient = createSupabaseBrowserClient() + + const { error } = await supabaseClient.auth.resetPasswordForEmail( + emailAddress, + { + redirectTo: `${window.location.origin}/auth/callback?next=/reset-password`, + }, + ) + + if (error) { + setErrorMessage(error.message) + setIsSubmitting(false) + return + } + + setIsEmailSent(true) + } + + if (isEmailSent) { + return ( + <> + <div className="space-y-2"> + <h1 className="text-lg text-text-primary">check your email</h1> + <p className="text-text-secondary"> + we sent a password reset link to {emailAddress} + </p> + </div> + <Link + href="/sign-in" + className="block text-text-secondary transition-colors hover:text-text-primary" + > + back to sign in + </Link> + </> + ) + } + + return ( + <> + <div className="space-y-2"> + <h1 className="text-lg text-text-primary">forgot password</h1> + <p className="text-text-secondary"> + enter your email to receive a reset link + </p> + </div> + + <form onSubmit={handleResetRequest} className="space-y-4"> + <div className="space-y-2"> + <label htmlFor="email" className="text-text-secondary"> + email + </label> + <input + id="email" + type="email" + value={emailAddress} + onChange={(event) => setEmailAddress(event.target.value)} + required + className="w-full border border-border bg-background-secondary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + placeholder="[email protected]" + /> + </div> + + {errorMessage && ( + <p className="text-status-error">{errorMessage}</p> + )} + + <button + type="submit" + disabled={isSubmitting} + className="w-full border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isSubmitting ? "sending reset link..." : "send reset link"} + </button> + </form> + + <Link + href="/sign-in" + className="block text-text-secondary transition-colors hover:text-text-primary" + > + back to sign in + </Link> + </> + ) +} diff --git a/apps/web/app/(auth)/layout.tsx b/apps/web/app/(auth)/layout.tsx new file mode 100644 index 0000000..6707b36 --- /dev/null +++ b/apps/web/app/(auth)/layout.tsx @@ -0,0 +1,11 @@ +export default function AuthLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + <div className="flex min-h-screen items-center justify-center px-4"> + <div className="w-full max-w-sm space-y-6">{children}</div> + </div> + ) +} diff --git a/apps/web/app/(auth)/reset-password/page.tsx b/apps/web/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..cb7432a --- /dev/null +++ b/apps/web/app/(auth)/reset-password/page.tsx @@ -0,0 +1,120 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" + +export default function ResetPasswordPage() { + const [newPassword, setNewPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") + const [errorMessage, setErrorMessage] = useState<string | null>(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [isPasswordUpdated, setIsPasswordUpdated] = useState(false) + const router = useRouter() + + async function handlePasswordUpdate(event: React.FormEvent) { + event.preventDefault() + setErrorMessage(null) + + if (newPassword !== confirmPassword) { + setErrorMessage("passwords do not match") + return + } + + setIsSubmitting(true) + + const supabaseClient = createSupabaseBrowserClient() + + const { error } = await supabaseClient.auth.updateUser({ + password: newPassword, + }) + + if (error) { + setErrorMessage(error.message) + setIsSubmitting(false) + return + } + + setIsPasswordUpdated(true) + } + + if (isPasswordUpdated) { + return ( + <> + <div className="space-y-2"> + <h1 className="text-lg text-text-primary">password updated</h1> + <p className="text-text-secondary"> + your password has been changed successfully + </p> + </div> + <Link + href="/reader" + className="block text-text-secondary transition-colors hover:text-text-primary" + > + go to reader + </Link> + </> + ) + } + + return ( + <> + <div className="space-y-2"> + <h1 className="text-lg text-text-primary">reset password</h1> + <p className="text-text-secondary">enter your new password</p> + </div> + + <form onSubmit={handlePasswordUpdate} className="space-y-4"> + <div className="space-y-2"> + <label htmlFor="new-password" className="text-text-secondary"> + new password + </label> + <input + id="new-password" + type="password" + value={newPassword} + onChange={(event) => setNewPassword(event.target.value)} + required + minLength={8} + className="w-full border border-border bg-background-secondary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + </div> + + <div className="space-y-2"> + <label htmlFor="confirm-password" className="text-text-secondary"> + confirm password + </label> + <input + id="confirm-password" + type="password" + value={confirmPassword} + onChange={(event) => setConfirmPassword(event.target.value)} + required + minLength={8} + className="w-full border border-border bg-background-secondary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + </div> + + {errorMessage && ( + <p className="text-status-error">{errorMessage}</p> + )} + + <button + type="submit" + disabled={isSubmitting} + className="w-full border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isSubmitting ? "updating password..." : "update password"} + </button> + </form> + + <Link + href="/sign-in" + className="block text-text-secondary transition-colors hover:text-text-primary" + > + back to sign in + </Link> + </> + ) +} diff --git a/apps/web/app/(auth)/sign-in/page.tsx b/apps/web/app/(auth)/sign-in/page.tsx new file mode 100644 index 0000000..b7426d2 --- /dev/null +++ b/apps/web/app/(auth)/sign-in/page.tsx @@ -0,0 +1,230 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" + +type SignInMode = "password" | "magic-link" + +export default function SignInPage() { + const [signInMode, setSignInMode] = useState<SignInMode>("password") + const [emailAddress, setEmailAddress] = useState("") + const [password, setPassword] = useState("") + const [errorMessage, setErrorMessage] = useState<string | null>(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [isMagicLinkSent, setIsMagicLinkSent] = useState(false) + const router = useRouter() + + async function handlePasswordSignIn(event: React.FormEvent) { + event.preventDefault() + setIsSubmitting(true) + setErrorMessage(null) + + const supabaseClient = createSupabaseBrowserClient() + + const { error } = await supabaseClient.auth.signInWithPassword({ + email: emailAddress, + password, + }) + + if (error) { + setErrorMessage(error.message) + setIsSubmitting(false) + return + } + + router.push("/reader") + router.refresh() + } + + async function handleMagicLinkSignIn(event: React.FormEvent) { + event.preventDefault() + setIsSubmitting(true) + setErrorMessage(null) + + const supabaseClient = createSupabaseBrowserClient() + + const { error } = await supabaseClient.auth.signInWithOtp({ + email: emailAddress, + options: { + shouldCreateUser: false, + emailRedirectTo: `${window.location.origin}/auth/callback?next=/reader`, + }, + }) + + setIsSubmitting(false) + + if (error) { + if (error.message.toLowerCase().includes("signups not allowed")) { + setIsMagicLinkSent(true) + return + } + setErrorMessage("something went wrong — please try again") + return + } + + setIsMagicLinkSent(true) + } + + if (isMagicLinkSent) { + return ( + <> + <div className="space-y-2"> + <h1 className="text-lg text-text-primary">check your email</h1> + <p className="text-text-secondary"> + if an account exists for {emailAddress}, we sent a sign-in link + </p> + </div> + <button + onClick={() => { + setIsMagicLinkSent(false) + setEmailAddress("") + }} + className="block text-text-secondary transition-colors hover:text-text-primary" + > + try a different email + </button> + <Link + href="/sign-in" + className="block text-text-secondary transition-colors hover:text-text-primary" + > + back to sign in + </Link> + </> + ) + } + + return ( + <> + <div className="space-y-2"> + <h1 className="text-lg text-text-primary">sign in</h1> + <p className="text-text-secondary"> + {signInMode === "password" + ? "enter your credentials to continue" + : "we\u2019ll send a sign-in link to your email"} + </p> + </div> + + <div className="flex border border-border"> + <button + type="button" + onClick={() => { + setSignInMode("password") + setErrorMessage(null) + }} + className={`flex-1 px-3 py-2 transition-colors ${ + signInMode === "password" + ? "bg-background-tertiary text-text-primary" + : "text-text-dim hover:text-text-secondary" + }`} + > + password + </button> + <button + type="button" + onClick={() => { + setSignInMode("magic-link") + setErrorMessage(null) + }} + className={`flex-1 px-3 py-2 transition-colors ${ + signInMode === "magic-link" + ? "bg-background-tertiary text-text-primary" + : "text-text-dim hover:text-text-secondary" + }`} + > + magic link + </button> + </div> + + {signInMode === "password" ? ( + <form onSubmit={handlePasswordSignIn} className="space-y-4"> + <div className="space-y-2"> + <label htmlFor="email" className="text-text-secondary"> + email + </label> + <input + id="email" + type="email" + value={emailAddress} + onChange={(event) => setEmailAddress(event.target.value)} + required + className="w-full border border-border bg-background-secondary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + placeholder="[email protected]" + /> + </div> + + <div className="space-y-2"> + <label htmlFor="password" className="text-text-secondary"> + password + </label> + <input + id="password" + type="password" + value={password} + onChange={(event) => setPassword(event.target.value)} + required + className="w-full border border-border bg-background-secondary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + </div> + + {errorMessage && ( + <p className="text-status-error">{errorMessage}</p> + )} + + <button + type="submit" + disabled={isSubmitting} + className="w-full border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isSubmitting ? "signing in..." : "sign in"} + </button> + </form> + ) : ( + <form onSubmit={handleMagicLinkSignIn} className="space-y-4"> + <div className="space-y-2"> + <label htmlFor="magic-email" className="text-text-secondary"> + email + </label> + <input + id="magic-email" + type="email" + value={emailAddress} + onChange={(event) => setEmailAddress(event.target.value)} + required + className="w-full border border-border bg-background-secondary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + placeholder="[email protected]" + /> + </div> + + {errorMessage && ( + <p className="text-status-error">{errorMessage}</p> + )} + + <button + type="submit" + disabled={isSubmitting} + className="w-full border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isSubmitting ? "sending link..." : "send magic link"} + </button> + </form> + )} + + <div className="space-y-2 text-text-secondary"> + <Link + href="/forgot-password" + className="block transition-colors hover:text-text-primary" + > + forgot password? + </Link> + <Link + href="/sign-up" + className="block transition-colors hover:text-text-primary" + > + don't have an account? sign up + </Link> + </div> + </> + ) +} diff --git a/apps/web/app/(auth)/sign-up/page.tsx b/apps/web/app/(auth)/sign-up/page.tsx new file mode 100644 index 0000000..9b78d90 --- /dev/null +++ b/apps/web/app/(auth)/sign-up/page.tsx @@ -0,0 +1,115 @@ +"use client" + +import { useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" + +export default function SignUpPage() { + const [emailAddress, setEmailAddress] = useState("") + const [password, setPassword] = useState("") + const [errorMessage, setErrorMessage] = useState<string | null>(null) + const [isSubmitting, setIsSubmitting] = useState(false) + const [isComplete, setIsComplete] = useState(false) + const router = useRouter() + + async function handleSignUp(event: React.FormEvent) { + event.preventDefault() + setIsSubmitting(true) + setErrorMessage(null) + + const supabaseClient = createSupabaseBrowserClient() + + const { error } = await supabaseClient.auth.signUp({ + email: emailAddress, + password, + }) + + if (error) { + setErrorMessage(error.message) + setIsSubmitting(false) + return + } + + setIsComplete(true) + } + + if (isComplete) { + return ( + <> + <div className="space-y-2"> + <h1 className="text-lg text-text-primary">check your email</h1> + <p className="text-text-secondary"> + we sent a confirmation link to {emailAddress} + </p> + </div> + <Link + href="/sign-in" + className="block text-text-secondary transition-colors hover:text-text-primary" + > + back to sign in + </Link> + </> + ) + } + + return ( + <> + <div className="space-y-2"> + <h1 className="text-lg text-text-primary">sign up</h1> + <p className="text-text-secondary">create your asa.news account</p> + </div> + + <form onSubmit={handleSignUp} className="space-y-4"> + <div className="space-y-2"> + <label htmlFor="email" className="text-text-secondary"> + email + </label> + <input + id="email" + type="email" + value={emailAddress} + onChange={(event) => setEmailAddress(event.target.value)} + required + className="w-full border border-border bg-background-secondary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + placeholder="[email protected]" + /> + </div> + + <div className="space-y-2"> + <label htmlFor="password" className="text-text-secondary"> + password + </label> + <input + id="password" + type="password" + value={password} + onChange={(event) => setPassword(event.target.value)} + required + minLength={8} + className="w-full border border-border bg-background-secondary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + </div> + + {errorMessage && ( + <p className="text-status-error">{errorMessage}</p> + )} + + <button + type="submit" + disabled={isSubmitting} + className="w-full border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isSubmitting ? "creating account..." : "sign up"} + </button> + </form> + + <Link + href="/sign-in" + className="block text-text-secondary transition-colors hover:text-text-primary" + > + already have an account? sign in + </Link> + </> + ) +} |