summaryrefslogtreecommitdiff
path: root/apps/web/app/(auth)
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-07 01:42:57 -0800
committerFuwn <[email protected]>2026-02-07 01:42:57 -0800
commit5c5b1993edd890a80870ee05607ac5f088191d4e (patch)
treea721b76bcd49ba10826c53efc87302c7a689512f /apps/web/app/(auth)
downloadasa.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.tsx101
-rw-r--r--apps/web/app/(auth)/layout.tsx11
-rw-r--r--apps/web/app/(auth)/reset-password/page.tsx120
-rw-r--r--apps/web/app/(auth)/sign-in/page.tsx230
-rw-r--r--apps/web/app/(auth)/sign-up/page.tsx115
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&apos;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>
+ </>
+ )
+}