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/reader/settings/_components | |
| 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/reader/settings/_components')
12 files changed, 2936 insertions, 0 deletions
diff --git a/apps/web/app/reader/settings/_components/account-settings.tsx b/apps/web/app/reader/settings/_components/account-settings.tsx new file mode 100644 index 0000000..b9ed8c3 --- /dev/null +++ b/apps/web/app/reader/settings/_components/account-settings.tsx @@ -0,0 +1,368 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { queryKeys } from "@/lib/queries/query-keys" +import { TIER_LIMITS } from "@asa-news/shared" +import { notify } from "@/lib/notify" + +export function AccountSettings() { + const { data: userProfile, isLoading } = useUserProfile() + const [isEditingName, setIsEditingName] = useState(false) + const [editedName, setEditedName] = useState("") + const [isRequestingData, setIsRequestingData] = useState(false) + const [newEmailAddress, setNewEmailAddress] = useState("") + const [emailPassword, setEmailPassword] = useState("") + const [currentPassword, setCurrentPassword] = useState("") + const [newPassword, setNewPassword] = useState("") + const [confirmNewPassword, setConfirmNewPassword] = useState("") + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + const router = useRouter() + + const updateDisplayName = useMutation({ + mutationFn: async (displayName: string | null) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient + .from("user_profiles") + .update({ display_name: displayName }) + .eq("id", user.id) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("display name updated") + }, + onError: (error: Error) => { + notify("failed to update display name: " + error.message) + }, + }) + + const updateEmailAddress = useMutation({ + mutationFn: async ({ + emailAddress, + password, + }: { + emailAddress: string + password: string + }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user?.email) throw new Error("Not authenticated") + + const { error: signInError } = await supabaseClient.auth.signInWithPassword({ + email: user.email, + password, + }) + + if (signInError) throw new Error("incorrect password") + + const { error } = await supabaseClient.auth.updateUser({ + email: emailAddress, + }) + + if (error) throw error + }, + onSuccess: () => { + setNewEmailAddress("") + setEmailPassword("") + notify("confirmation email sent to your new address") + }, + onError: (error: Error) => { + notify("failed to update email: " + error.message) + }, + }) + + const updatePassword = useMutation({ + mutationFn: async ({ + currentPassword: current, + newPassword: updated, + }: { + currentPassword: string + newPassword: string + }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user?.email) throw new Error("Not authenticated") + + const { error: signInError } = await supabaseClient.auth.signInWithPassword({ + email: user.email, + password: current, + }) + + if (signInError) throw new Error("current password is incorrect") + + const { error } = await supabaseClient.auth.updateUser({ + password: updated, + }) + + if (error) throw error + }, + onSuccess: async () => { + setCurrentPassword("") + setNewPassword("") + setConfirmNewPassword("") + notify("password updated — signing out all sessions") + await supabaseClient.auth.signOut({ scope: "global" }) + router.push("/sign-in") + }, + onError: (error: Error) => { + notify("failed to update password: " + error.message) + }, + }) + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading account ...</p> + } + + if (!userProfile) { + return <p className="px-4 py-6 text-text-dim">failed to load account</p> + } + + const tier = userProfile.tier + const tierLimits = TIER_LIMITS[tier] + + async function handleRequestData() { + setIsRequestingData(true) + try { + const response = await fetch("/api/account/data") + if (!response.ok) throw new Error("Export failed") + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = `asa-news-gdpr-export-${new Date().toISOString().slice(0, 10)}.json` + anchor.click() + URL.revokeObjectURL(url) + notify("data exported") + } catch { + notify("failed to export data") + } finally { + setIsRequestingData(false) + } + } + + function handleSaveName() { + const trimmedName = editedName.trim() + updateDisplayName.mutate(trimmedName || null) + setIsEditingName(false) + } + + function handleUpdateEmail(event: React.FormEvent) { + event.preventDefault() + const trimmedEmail = newEmailAddress.trim() + if (!trimmedEmail || !emailPassword) return + updateEmailAddress.mutate({ emailAddress: trimmedEmail, password: emailPassword }) + } + + function handleUpdatePassword(event: React.FormEvent) { + event.preventDefault() + if (!currentPassword) { + notify("current password is required") + return + } + if (!newPassword || newPassword !== confirmNewPassword) { + notify("passwords do not match") + return + } + if (newPassword.length < 8) { + notify("password must be at least 8 characters") + return + } + updatePassword.mutate({ currentPassword, newPassword }) + } + + return ( + <div className="px-4 py-3"> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">display name</h3> + {isEditingName ? ( + <div className="flex items-center gap-2"> + <input + type="text" + value={editedName} + onChange={(event) => setEditedName(event.target.value)} + placeholder="display name" + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + onKeyDown={(event) => { + if (event.key === "Enter") handleSaveName() + if (event.key === "Escape") setIsEditingName(false) + }} + autoFocus + /> + <button + onClick={handleSaveName} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + onClick={() => setIsEditingName(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : ( + <div className="flex items-center gap-2"> + <span className="text-text-secondary"> + {userProfile.displayName ?? "not set"} + </span> + <button + onClick={() => { + setEditedName(userProfile.displayName ?? "") + setIsEditingName(true) + }} + className="px-2 py-1 text-text-dim transition-colors hover:text-text-secondary" + > + edit + </button> + </div> + )} + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">email address</h3> + <p className="mb-2 text-text-dim"> + {userProfile.email ?? "no email on file"} + </p> + <form onSubmit={handleUpdateEmail} className="space-y-2"> + <input + type="email" + value={newEmailAddress} + onChange={(event) => setNewEmailAddress(event.target.value)} + placeholder="new email address" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <input + type="password" + value={emailPassword} + onChange={(event) => setEmailPassword(event.target.value)} + placeholder="current password" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + type="submit" + disabled={updateEmailAddress.isPending || !newEmailAddress.trim() || !emailPassword} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + update email + </button> + </form> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">change password</h3> + <form onSubmit={handleUpdatePassword} className="space-y-2"> + <input + type="password" + value={currentPassword} + onChange={(event) => setCurrentPassword(event.target.value)} + placeholder="current password" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <input + type="password" + value={newPassword} + onChange={(event) => setNewPassword(event.target.value)} + placeholder="new password" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <input + type="password" + value={confirmNewPassword} + onChange={(event) => setConfirmNewPassword(event.target.value)} + placeholder="confirm new password" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + type="submit" + disabled={updatePassword.isPending || !currentPassword || !newPassword || newPassword !== confirmNewPassword} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + change password + </button> + </form> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">usage</h3> + <div className="space-y-1"> + <UsageRow + label="feeds" + current={userProfile.feedCount} + maximum={tierLimits.maximumFeeds} + /> + <UsageRow + label="folders" + current={userProfile.folderCount} + maximum={tierLimits.maximumFolders} + /> + <UsageRow + label="muted keywords" + current={userProfile.mutedKeywordCount} + maximum={tierLimits.maximumMutedKeywords} + /> + <UsageRow + label="custom feeds" + current={userProfile.customFeedCount} + maximum={tierLimits.maximumCustomFeeds} + /> + </div> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">your data</h3> + <p className="mb-3 text-text-dim"> + download all your data (profile, subscriptions, folders, highlights, saved entries) + </p> + <button + onClick={handleRequestData} + disabled={isRequestingData} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isRequestingData ? "exporting ..." : "request all data"} + </button> + </div> + </div> + ) +} + +function UsageRow({ + label, + current, + maximum, +}: { + label: string + current: number + maximum: number +}) { + const isNearLimit = current >= maximum * 0.8 + const isAtLimit = current >= maximum + + return ( + <div className="flex items-center justify-between py-1"> + <span className="text-text-secondary">{label}</span> + <span + className={ + isAtLimit + ? "text-status-error" + : isNearLimit + ? "text-text-primary" + : "text-text-dim" + } + > + {current} / {maximum} + </span> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/api-settings.tsx b/apps/web/app/reader/settings/_components/api-settings.tsx new file mode 100644 index 0000000..cb69958 --- /dev/null +++ b/apps/web/app/reader/settings/_components/api-settings.tsx @@ -0,0 +1,529 @@ +"use client" + +import { useState, useEffect } from "react" +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { queryKeys } from "@/lib/queries/query-keys" +import { notify } from "@/lib/notify" + +interface ApiKey { + keyIdentifier: string + keyPrefix: string + label: string | null + createdAt: string + lastUsedAt: string | null +} + +interface WebhookConfiguration { + webhookUrl: string | null + webhookSecret: string | null + webhookEnabled: boolean + consecutiveFailures: number +} + +function useApiKeys() { + return useQuery({ + queryKey: ["apiKeys"], + queryFn: async () => { + const response = await fetch("/api/v1/keys") + if (!response.ok) throw new Error("Failed to load API keys") + const data = await response.json() + return data.keys as ApiKey[] + }, + }) +} + +function useCreateApiKey() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (label: string | null) => { + const response = await fetch("/api/v1/keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ label }), + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to create API key") + } + return response.json() as Promise<{ + fullKey: string + keyPrefix: string + keyIdentifier: string + }> + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["apiKeys"] }) + }, + }) +} + +function useRevokeApiKey() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (keyIdentifier: string) => { + const response = await fetch(`/api/v1/keys/${keyIdentifier}`, { + method: "DELETE", + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to revoke API key") + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["apiKeys"] }) + }, + }) +} + +function useWebhookConfig() { + return useQuery({ + queryKey: ["webhookConfig"], + queryFn: async () => { + const response = await fetch("/api/webhook-config") + if (!response.ok) throw new Error("Failed to load webhook config") + return response.json() as Promise<WebhookConfiguration> + }, + }) +} + +function useUpdateWebhookConfig() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ( + updates: Partial<{ + webhookUrl: string + webhookSecret: string + webhookEnabled: boolean + }> + ) => { + const response = await fetch("/api/webhook-config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(updates), + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to update webhook config") + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["webhookConfig"] }) + }, + }) +} + +function useTestWebhook() { + return useMutation({ + mutationFn: async () => { + const response = await fetch("/api/webhook-config/test", { + method: "POST", + }) + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to send test webhook") + } + return response.json() as Promise<{ + delivered: boolean + statusCode?: number + error?: string + }> + }, + }) +} + +function ApiKeysSection() { + const { data: apiKeys, isLoading } = useApiKeys() + const createApiKey = useCreateApiKey() + const revokeApiKey = useRevokeApiKey() + const [newKeyLabel, setNewKeyLabel] = useState("") + const [revealedKey, setRevealedKey] = useState<string | null>(null) + const [confirmRevokeIdentifier, setConfirmRevokeIdentifier] = useState< + string | null + >(null) + + function handleCreateKey() { + createApiKey.mutate(newKeyLabel.trim() || null, { + onSuccess: (data) => { + setRevealedKey(data.fullKey) + setNewKeyLabel("") + notify("API key created") + }, + onError: (error: Error) => { + notify(error.message) + }, + }) + } + + function handleCopyKey() { + if (revealedKey) { + navigator.clipboard.writeText(revealedKey) + notify("API key copied to clipboard") + } + } + + function handleRevokeKey(keyIdentifier: string) { + revokeApiKey.mutate(keyIdentifier, { + onSuccess: () => { + notify("API key revoked") + setConfirmRevokeIdentifier(null) + }, + onError: (error: Error) => { + notify(error.message) + }, + }) + } + + return ( + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">API keys</h3> + <p className="mb-3 text-text-dim"> + use API keys to authenticate requests to the REST API + </p> + + {revealedKey && ( + <div className="mb-4 border border-status-warning p-3"> + <p className="mb-2 text-text-secondary"> + copy this key now — it will not be shown again + </p> + <div className="flex items-center gap-2"> + <code className="min-w-0 flex-1 overflow-x-auto bg-background-tertiary px-2 py-1 text-text-primary"> + {revealedKey} + </code> + <button + onClick={handleCopyKey} + className="shrink-0 border border-border px-3 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + copy + </button> + </div> + <button + onClick={() => setRevealedKey(null)} + className="mt-2 text-text-dim transition-colors hover:text-text-secondary" + > + dismiss + </button> + </div> + )} + + {isLoading ? ( + <p className="text-text-dim">loading keys ...</p> + ) : ( + <> + {apiKeys && apiKeys.length > 0 && ( + <div className="mb-4 border border-border"> + {apiKeys.map((apiKey) => ( + <div + key={apiKey.keyIdentifier} + className="flex items-center justify-between border-b border-border px-3 py-2 last:border-b-0" + > + <div className="min-w-0 flex-1"> + <div className="flex items-center gap-2"> + <code className="text-text-primary"> + {apiKey.keyPrefix}... + </code> + {apiKey.label && ( + <span className="text-text-dim">{apiKey.label}</span> + )} + </div> + <div className="text-text-dim"> + created{" "} + {new Date(apiKey.createdAt).toLocaleDateString()} + {apiKey.lastUsedAt && + ` · last used ${new Date(apiKey.lastUsedAt).toLocaleDateString()}`} + </div> + </div> + {confirmRevokeIdentifier === apiKey.keyIdentifier ? ( + <div className="flex items-center gap-1"> + <span className="text-text-dim">revoke?</span> + <button + onClick={() => + handleRevokeKey(apiKey.keyIdentifier) + } + className="px-2 py-1 text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + onClick={() => setConfirmRevokeIdentifier(null)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + onClick={() => + setConfirmRevokeIdentifier(apiKey.keyIdentifier) + } + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error" + > + revoke + </button> + )} + </div> + ))} + </div> + )} + + {(!apiKeys || apiKeys.length < 5) && ( + <div className="flex items-center gap-2"> + <input + type="text" + value={newKeyLabel} + onChange={(event) => setNewKeyLabel(event.target.value)} + placeholder="key label (optional)" + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + onKeyDown={(event) => { + if (event.key === "Enter") handleCreateKey() + }} + /> + <button + onClick={handleCreateKey} + disabled={createApiKey.isPending} + className="shrink-0 border border-border bg-background-tertiary px-4 py-1.5 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {createApiKey.isPending ? "creating ..." : "create key"} + </button> + </div> + )} + </> + )} + </div> + ) +} + +function WebhookSection() { + const { data: webhookConfig, isLoading } = useWebhookConfig() + const updateWebhookConfig = useUpdateWebhookConfig() + const testWebhook = useTestWebhook() + const [webhookUrl, setWebhookUrl] = useState("") + const [webhookSecret, setWebhookSecret] = useState("") + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) + + useEffect(() => { + if (webhookConfig) { + setWebhookUrl(webhookConfig.webhookUrl ?? "") + setWebhookSecret(webhookConfig.webhookSecret ?? "") + } + }, [webhookConfig]) + + function handleSaveWebhookConfig() { + updateWebhookConfig.mutate( + { + webhookUrl: webhookUrl.trim(), + webhookSecret: webhookSecret.trim(), + }, + { + onSuccess: () => { + notify("webhook configuration saved") + setHasUnsavedChanges(false) + }, + onError: (error: Error) => { + notify(error.message) + }, + } + ) + } + + function handleToggleEnabled() { + if (!webhookConfig) return + + updateWebhookConfig.mutate( + { webhookEnabled: !webhookConfig.webhookEnabled }, + { + onSuccess: () => { + notify( + webhookConfig.webhookEnabled + ? "webhooks disabled" + : "webhooks enabled" + ) + }, + onError: (error: Error) => { + notify(error.message) + }, + } + ) + } + + function handleTestWebhook() { + testWebhook.mutate(undefined, { + onSuccess: (data) => { + if (data.delivered) { + notify(`test webhook delivered (status ${data.statusCode})`) + } else { + notify(`test webhook failed: ${data.error}`) + } + }, + onError: (error: Error) => { + notify(error.message) + }, + }) + } + + function handleGenerateSecret() { + const array = new Uint8Array(32) + crypto.getRandomValues(array) + const generatedSecret = Array.from(array) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join("") + setWebhookSecret(generatedSecret) + setHasUnsavedChanges(true) + } + + if (isLoading) { + return <p className="text-text-dim">loading webhook configuration ...</p> + } + + return ( + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">webhooks</h3> + <p className="mb-3 text-text-dim"> + receive HTTP POST notifications when new entries arrive in your + subscribed feeds + </p> + + <div className="mb-4"> + <label className="mb-1 block text-text-secondary">webhook URL</label> + <input + type="url" + value={webhookUrl} + onChange={(event) => { + setWebhookUrl(event.target.value) + setHasUnsavedChanges(true) + }} + placeholder="https://example.com/webhook" + className="w-full border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + </div> + + <div className="mb-4"> + <label className="mb-1 block text-text-secondary"> + signing secret + </label> + <div className="flex items-center gap-2"> + <input + type="text" + value={webhookSecret} + onChange={(event) => { + setWebhookSecret(event.target.value) + setHasUnsavedChanges(true) + }} + placeholder="optional HMAC-SHA256 signing secret" + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + onClick={handleGenerateSecret} + className="shrink-0 border border-border px-3 py-1.5 text-text-secondary transition-colors hover:text-text-primary" + > + generate + </button> + </div> + </div> + + <div className="mb-4 flex items-center gap-4"> + <button + onClick={handleSaveWebhookConfig} + disabled={!hasUnsavedChanges || updateWebhookConfig.isPending} + className="border border-border bg-background-tertiary px-4 py-1.5 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {updateWebhookConfig.isPending ? "saving ..." : "save"} + </button> + <button + onClick={handleToggleEnabled} + disabled={updateWebhookConfig.isPending} + className="border border-border px-4 py-1.5 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-50" + > + {webhookConfig?.webhookEnabled ? "disable" : "enable"} + </button> + <button + onClick={handleTestWebhook} + disabled={ + testWebhook.isPending || + !webhookConfig?.webhookEnabled || + !webhookConfig?.webhookUrl + } + className="border border-border px-4 py-1.5 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-50" + > + {testWebhook.isPending ? "sending ..." : "send test"} + </button> + </div> + + {webhookConfig && ( + <div className="flex items-center gap-3 text-text-dim"> + <span> + status:{" "} + <span + className={ + webhookConfig.webhookEnabled + ? "text-text-primary" + : "text-text-dim" + } + > + {webhookConfig.webhookEnabled ? "enabled" : "disabled"} + </span> + </span> + {webhookConfig.consecutiveFailures > 0 && ( + <span className="text-status-warning"> + {webhookConfig.consecutiveFailures} consecutive failure + {webhookConfig.consecutiveFailures !== 1 && "s"} + </span> + )} + </div> + )} + </div> + ) +} + +export function ApiSettings() { + const { data: userProfile, isLoading } = useUserProfile() + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading API settings ...</p> + } + + if (!userProfile) { + return ( + <p className="px-4 py-6 text-text-dim">failed to load API settings</p> + ) + } + + if (userProfile.tier !== "developer") { + return ( + <div className="px-4 py-3"> + <h3 className="mb-2 text-text-primary">developer API</h3> + <p className="mb-3 text-text-dim"> + the developer plan includes a read-only REST API and webhook push + notifications. upgrade to developer to access these features. + </p> + </div> + ) + } + + return ( + <div className="px-4 py-3"> + <ApiKeysSection /> + <WebhookSection /> + + <div> + <h3 className="mb-2 text-text-primary">API documentation</h3> + <p className="mb-3 text-text-dim"> + authenticate requests with an API key in the Authorization header: + </p> + <code className="block bg-background-tertiary px-3 py-2 text-text-secondary"> + Authorization: Bearer asn_your_key_here + </code> + <div className="mt-3 space-y-1 text-text-dim"> + <p>GET /api/v1/profile — your account info and limits</p> + <p>GET /api/v1/feeds — your subscribed feeds</p> + <p>GET /api/v1/folders — your folders</p> + <p> + GET /api/v1/entries — entries with ?cursor, ?limit, ?feedIdentifier, + ?readStatus, ?savedStatus filters + </p> + <p>GET /api/v1/entries/:id — single entry with full content</p> + </div> + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/appearance-settings.tsx b/apps/web/app/reader/settings/_components/appearance-settings.tsx new file mode 100644 index 0000000..9c0e214 --- /dev/null +++ b/apps/web/app/reader/settings/_components/appearance-settings.tsx @@ -0,0 +1,123 @@ +"use client" + +import { useTheme } from "next-themes" +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" + +export function AppearanceSettings() { + const { theme, setTheme } = useTheme() + const entryListViewMode = useUserInterfaceStore( + (state) => state.entryListViewMode + ) + const setEntryListViewMode = useUserInterfaceStore( + (state) => state.setEntryListViewMode + ) + const displayDensity = useUserInterfaceStore( + (state) => state.displayDensity + ) + const setDisplayDensity = useUserInterfaceStore( + (state) => state.setDisplayDensity + ) + const showFeedFavicons = useUserInterfaceStore( + (state) => state.showFeedFavicons + ) + const setShowFeedFavicons = useUserInterfaceStore( + (state) => state.setShowFeedFavicons + ) + const focusFollowsInteraction = useUserInterfaceStore( + (state) => state.focusFollowsInteraction + ) + const setFocusFollowsInteraction = useUserInterfaceStore( + (state) => state.setFocusFollowsInteraction + ) + + return ( + <div className="px-4 py-3"> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">theme</h3> + <p className="mb-3 text-text-dim"> + controls the colour scheme of the application + </p> + <select + value={theme ?? "system"} + onChange={(event) => setTheme(event.target.value)} + className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none focus:border-text-dim" + > + <option value="system">system</option> + <option value="light">light</option> + <option value="dark">dark</option> + </select> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">display density</h3> + <p className="mb-3 text-text-dim"> + controls the overall text size and spacing + </p> + <select + value={displayDensity} + onChange={(event) => + setDisplayDensity( + event.target.value as "compact" | "default" | "spacious" + ) + } + className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none focus:border-text-dim" + > + <option value="compact">compact</option> + <option value="default">default</option> + <option value="spacious">spacious</option> + </select> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">entry list view</h3> + <p className="mb-3 text-text-dim"> + controls how entries are displayed in the list + </p> + <select + value={entryListViewMode} + onChange={(event) => + setEntryListViewMode( + event.target.value as "compact" | "comfortable" | "expanded" + ) + } + className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none focus:border-text-dim" + > + <option value="compact">compact</option> + <option value="comfortable">comfortable</option> + <option value="expanded">expanded</option> + </select> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">feed favicons</h3> + <p className="mb-3 text-text-dim"> + show website icons next to feed names in the sidebar + </p> + <label className="flex cursor-pointer items-center gap-2 text-text-primary"> + <input + type="checkbox" + checked={showFeedFavicons} + onChange={(event) => setShowFeedFavicons(event.target.checked)} + className="accent-text-primary" + /> + <span>show favicons</span> + </label> + </div> + <div> + <h3 className="mb-2 text-text-primary">focus follows interaction</h3> + <p className="mb-3 text-text-dim"> + automatically move keyboard panel focus to the last pane you + interacted with (clicked or scrolled) + </p> + <label className="flex cursor-pointer items-center gap-2 text-text-primary"> + <input + type="checkbox" + checked={focusFollowsInteraction} + onChange={(event) => + setFocusFollowsInteraction(event.target.checked) + } + className="accent-text-primary" + /> + <span>enable focus follows interaction</span> + </label> + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/billing-settings.tsx b/apps/web/app/reader/settings/_components/billing-settings.tsx new file mode 100644 index 0000000..e49720a --- /dev/null +++ b/apps/web/app/reader/settings/_components/billing-settings.tsx @@ -0,0 +1,301 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useSearchParams } from "next/navigation" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { queryKeys } from "@/lib/queries/query-keys" +import { TIER_LIMITS } from "@asa-news/shared" +import { classNames } from "@/lib/utilities" +import { notify } from "@/lib/notify" + +function useCreateCheckoutSession() { + return useMutation({ + mutationFn: async ({ + billingInterval, + targetTier, + }: { + billingInterval: "monthly" | "yearly" + targetTier: "pro" | "developer" + }) => { + const response = await fetch("/api/billing/create-checkout-session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ billingInterval, targetTier }), + }) + + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to create checkout session") + } + + const data = await response.json() + return data as { url?: string; upgraded?: boolean } + }, + }) +} + +function useCreatePortalSession() { + return useMutation({ + mutationFn: async () => { + const response = await fetch("/api/billing/create-portal-session", { + method: "POST", + }) + + if (!response.ok) { + const data = await response.json() + throw new Error(data.error || "Failed to create portal session") + } + + const data = await response.json() + return data as { url: string } + }, + }) +} + +const PRO_FEATURES = [ + `${TIER_LIMITS.pro.maximumFeeds} feeds`, + `${Number.isFinite(TIER_LIMITS.pro.historyRetentionDays) ? TIER_LIMITS.pro.historyRetentionDays.toLocaleString() + " days" : "unlimited"} history retention`, + `${TIER_LIMITS.pro.refreshIntervalSeconds / 60}-minute refresh interval`, + "authenticated feeds", + "OPML export", + "manual feed refresh", +] + +const DEVELOPER_FEATURES = [ + `${TIER_LIMITS.developer.maximumFeeds} feeds`, + "everything in pro", + "read-only REST API", + "webhook push notifications", +] + +function UpgradeCard({ + targetTier, + features, + monthlyPrice, + yearlyPrice, +}: { + targetTier: "pro" | "developer" + features: string[] + monthlyPrice: string + yearlyPrice: string +}) { + const [billingInterval, setBillingInterval] = useState< + "monthly" | "yearly" + >("yearly") + const queryClient = useQueryClient() + const createCheckoutSession = useCreateCheckoutSession() + + function handleUpgrade() { + createCheckoutSession.mutate( + { billingInterval, targetTier }, + { + onSuccess: (data) => { + if (data.upgraded) { + queryClient.invalidateQueries({ + queryKey: queryKeys.userProfile.all, + }) + notify(`upgraded to ${targetTier}!`) + } else if (data.url) { + window.location.href = data.url + } + }, + onError: (error: Error) => { + notify("failed to start checkout: " + error.message) + }, + } + ) + } + + return ( + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">upgrade to {targetTier}</h3> + <ul className="mb-4 space-y-1"> + {features.map((feature) => ( + <li key={feature} className="text-text-secondary"> + + {feature} + </li> + ))} + </ul> + <div className="mb-4 flex items-center gap-2"> + <button + type="button" + onClick={() => setBillingInterval("monthly")} + className={classNames( + "border px-3 py-1 transition-colors", + billingInterval === "monthly" + ? "border-text-primary text-text-primary" + : "border-border text-text-dim hover:text-text-secondary" + )} + > + {monthlyPrice} / month + </button> + <button + type="button" + onClick={() => setBillingInterval("yearly")} + className={classNames( + "border px-3 py-1 transition-colors", + billingInterval === "yearly" + ? "border-text-primary text-text-primary" + : "border-border text-text-dim hover:text-text-secondary" + )} + > + {yearlyPrice} / year + </button> + </div> + <button + onClick={handleUpgrade} + disabled={createCheckoutSession.isPending} + className="border border-text-primary px-4 py-2 text-text-primary transition-colors hover:bg-text-primary hover:text-background-primary disabled:opacity-50" + > + {createCheckoutSession.isPending + ? "redirecting ..." + : `upgrade to ${targetTier}`} + </button> + </div> + ) +} + +export function BillingSettings() { + const { data: userProfile, isLoading } = useUserProfile() + const queryClient = useQueryClient() + const searchParameters = useSearchParams() + const hasShownSuccessToast = useRef(false) + + const createPortalSession = useCreatePortalSession() + + useEffect(() => { + if ( + searchParameters.get("billing") === "success" && + !hasShownSuccessToast.current + ) { + hasShownSuccessToast.current = true + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("subscription activated!") + const url = new URL(window.location.href) + url.searchParams.delete("billing") + window.history.replaceState({}, "", url.pathname) + } + }, [searchParameters, queryClient]) + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading billing ...</p> + } + + if (!userProfile) { + return <p className="px-4 py-6 text-text-dim">failed to load billing</p> + } + + function handleManageSubscription() { + createPortalSession.mutate(undefined, { + onSuccess: (data) => { + window.location.href = data.url + }, + onError: (error: Error) => { + notify("failed to open billing portal: " + error.message) + }, + }) + } + + const isPaidTier = + userProfile.tier === "pro" || userProfile.tier === "developer" + const isCancelling = + userProfile.stripeSubscriptionStatus === "active" && + isPaidTier && + userProfile.stripeCurrentPeriodEnd !== null + + return ( + <div className="px-4 py-3"> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">current plan</h3> + <span className="border border-border px-2 py-1 text-text-secondary"> + {userProfile.tier} + </span> + {userProfile.stripeSubscriptionStatus && ( + <span className="ml-2 text-text-dim"> + ({userProfile.stripeSubscriptionStatus}) + </span> + )} + </div> + + {userProfile.tier === "free" && ( + <> + <UpgradeCard + targetTier="pro" + features={PRO_FEATURES} + monthlyPrice="$3" + yearlyPrice="$30" + /> + <UpgradeCard + targetTier="developer" + features={DEVELOPER_FEATURES} + monthlyPrice="$6" + yearlyPrice="$60" + /> + </> + )} + + {userProfile.tier === "pro" && ( + <> + <UpgradeCard + targetTier="developer" + features={DEVELOPER_FEATURES} + monthlyPrice="$6" + yearlyPrice="$60" + /> + <div className="mb-6"> + {isCancelling && userProfile.stripeCurrentPeriodEnd && ( + <p className="mb-3 text-text-secondary"> + your pro plan is active until{" "} + {new Date( + userProfile.stripeCurrentPeriodEnd + ).toLocaleDateString()} + </p> + )} + {userProfile.stripeSubscriptionStatus === "past_due" && ( + <p className="mb-3 text-status-error"> + payment failed — please update your payment method + </p> + )} + <button + onClick={handleManageSubscription} + disabled={createPortalSession.isPending} + className="border border-border px-4 py-2 text-text-secondary transition-colors hover:border-text-dim hover:text-text-primary disabled:opacity-50" + > + {createPortalSession.isPending + ? "redirecting ..." + : "manage subscription"} + </button> + </div> + </> + )} + + {userProfile.tier === "developer" && ( + <div className="mb-6"> + {isCancelling && userProfile.stripeCurrentPeriodEnd && ( + <p className="mb-3 text-text-secondary"> + your developer plan is active until{" "} + {new Date( + userProfile.stripeCurrentPeriodEnd + ).toLocaleDateString()} + </p> + )} + {userProfile.stripeSubscriptionStatus === "past_due" && ( + <p className="mb-3 text-status-error"> + payment failed — please update your payment method + </p> + )} + <button + onClick={handleManageSubscription} + disabled={createPortalSession.isPending} + className="border border-border px-4 py-2 text-text-secondary transition-colors hover:border-text-dim hover:text-text-primary disabled:opacity-50" + > + {createPortalSession.isPending + ? "redirecting ..." + : "manage subscription"} + </button> + </div> + )} + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/custom-feeds-settings.tsx b/apps/web/app/reader/settings/_components/custom-feeds-settings.tsx new file mode 100644 index 0000000..b7b588b --- /dev/null +++ b/apps/web/app/reader/settings/_components/custom-feeds-settings.tsx @@ -0,0 +1,283 @@ +"use client" + +import { useState } from "react" +import { useCustomFeeds } from "@/lib/queries/use-custom-feeds" +import { + useCreateCustomFeed, + useUpdateCustomFeed, + useDeleteCustomFeed, +} from "@/lib/queries/use-custom-feed-mutations" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { TIER_LIMITS } from "@asa-news/shared" + +export function CustomFeedsSettings() { + const { data: customFeeds, isLoading } = useCustomFeeds() + const { data: subscriptionsData } = useSubscriptions() + const { data: userProfile } = useUserProfile() + const createCustomFeed = useCreateCustomFeed() + + const [newName, setNewName] = useState("") + const [newKeywords, setNewKeywords] = useState("") + const [newMatchMode, setNewMatchMode] = useState<"and" | "or">("or") + const [newSourceFolderId, setNewSourceFolderId] = useState<string>("") + + const folders = subscriptionsData?.folders ?? [] + const tier = userProfile?.tier ?? "free" + const maximumCustomFeeds = TIER_LIMITS[tier].maximumCustomFeeds + + function handleCreate(event: React.FormEvent) { + event.preventDefault() + const trimmedName = newName.trim() + const trimmedKeywords = newKeywords.trim() + + if (!trimmedName || !trimmedKeywords) return + + createCustomFeed.mutate({ + name: trimmedName, + query: trimmedKeywords, + matchMode: newMatchMode, + sourceFolderIdentifier: newSourceFolderId || null, + }) + + setNewName("") + setNewKeywords("") + setNewMatchMode("or") + setNewSourceFolderId("") + } + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading custom feeds ...</p> + } + + const feedsList = customFeeds ?? [] + + return ( + <div> + <div className="border-b border-border px-4 py-3"> + <p className="mb-2 text-text-dim"> + {feedsList.length} / {maximumCustomFeeds} custom feeds used + </p> + <form onSubmit={handleCreate} className="space-y-2"> + <input + type="text" + value={newName} + onChange={(event) => setNewName(event.target.value)} + placeholder="feed name" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <input + type="text" + value={newKeywords} + onChange={(event) => setNewKeywords(event.target.value)} + placeholder="keywords (space-separated)" + className="w-full border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <div className="flex gap-2"> + <select + value={newMatchMode} + onChange={(event) => + setNewMatchMode(event.target.value as "and" | "or") + } + className="border border-border bg-background-primary px-2 py-2 text-text-secondary outline-none" + > + <option value="or">match any keyword</option> + <option value="and">match all keywords</option> + </select> + <select + value={newSourceFolderId} + onChange={(event) => setNewSourceFolderId(event.target.value)} + className="border border-border bg-background-primary px-2 py-2 text-text-secondary outline-none" + > + <option value="">all feeds</option> + {folders.map((folder) => ( + <option key={folder.folderIdentifier} value={folder.folderIdentifier}> + {folder.name} + </option> + ))} + </select> + <button + type="submit" + disabled={ + createCustomFeed.isPending || + !newName.trim() || + !newKeywords.trim() + } + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + create + </button> + </div> + </form> + </div> + {feedsList.length === 0 ? ( + <p className="px-4 py-6 text-text-dim">no custom feeds yet</p> + ) : ( + feedsList.map((customFeed) => ( + <CustomFeedRow + key={customFeed.identifier} + customFeed={customFeed} + folders={folders} + /> + )) + )} + </div> + ) +} + +function CustomFeedRow({ + customFeed, + folders, +}: { + customFeed: { + identifier: string + name: string + query: string + matchMode: "and" | "or" + sourceFolderIdentifier: string | null + } + folders: { folderIdentifier: string; name: string }[] +}) { + const updateCustomFeed = useUpdateCustomFeed() + const deleteCustomFeed = useDeleteCustomFeed() + const [isEditing, setIsEditing] = useState(false) + const [editedName, setEditedName] = useState(customFeed.name) + const [editedKeywords, setEditedKeywords] = useState(customFeed.query) + const [editedMatchMode, setEditedMatchMode] = useState(customFeed.matchMode) + const [editedSourceFolderId, setEditedSourceFolderId] = useState( + customFeed.sourceFolderIdentifier ?? "" + ) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + function handleSave() { + const trimmedName = editedName.trim() + const trimmedKeywords = editedKeywords.trim() + + if (!trimmedName || !trimmedKeywords) return + + updateCustomFeed.mutate({ + customFeedIdentifier: customFeed.identifier, + name: trimmedName, + query: trimmedKeywords, + matchMode: editedMatchMode, + sourceFolderIdentifier: editedSourceFolderId || null, + }) + setIsEditing(false) + } + + const sourceFolderName = customFeed.sourceFolderIdentifier + ? folders.find( + (folder) => + folder.folderIdentifier === customFeed.sourceFolderIdentifier + )?.name ?? "unknown folder" + : "all feeds" + + return ( + <div className="border-b border-border px-4 py-3 last:border-b-0"> + {isEditing ? ( + <div className="space-y-2"> + <input + type="text" + value={editedName} + onChange={(event) => setEditedName(event.target.value)} + className="w-full border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim" + autoFocus + /> + <input + type="text" + value={editedKeywords} + onChange={(event) => setEditedKeywords(event.target.value)} + className="w-full border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim" + /> + <div className="flex gap-2"> + <select + value={editedMatchMode} + onChange={(event) => + setEditedMatchMode(event.target.value as "and" | "or") + } + className="border border-border bg-background-primary px-2 py-1 text-text-secondary outline-none" + > + <option value="or">match any</option> + <option value="and">match all</option> + </select> + <select + value={editedSourceFolderId} + onChange={(event) => setEditedSourceFolderId(event.target.value)} + className="border border-border bg-background-primary px-2 py-1 text-text-secondary outline-none" + > + <option value="">all feeds</option> + {folders.map((folder) => ( + <option + key={folder.folderIdentifier} + value={folder.folderIdentifier} + > + {folder.name} + </option> + ))} + </select> + </div> + <div className="flex gap-2"> + <button + onClick={handleSave} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + onClick={() => setIsEditing(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + </div> + ) : ( + <div> + <div className="flex items-center justify-between"> + <span className="text-text-primary">{customFeed.name}</span> + <div className="flex items-center gap-2"> + <button + onClick={() => setIsEditing(true)} + className="px-2 py-1 text-text-dim transition-colors hover:text-text-secondary" + > + edit + </button> + {showDeleteConfirm ? ( + <div className="flex items-center gap-1"> + <button + onClick={() => { + deleteCustomFeed.mutate({ + customFeedIdentifier: customFeed.identifier, + }) + setShowDeleteConfirm(false) + }} + className="px-2 py-1 text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + onClick={() => setShowDeleteConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + onClick={() => setShowDeleteConfirm(true)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error" + > + delete + </button> + )} + </div> + </div> + <p className="text-text-dim"> + keywords: {customFeed.query} ({customFeed.matchMode === "and" ? "all" : "any"}) + </p> + <p className="text-text-dim">source: {sourceFolderName}</p> + </div> + )} + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/danger-zone-settings.tsx b/apps/web/app/reader/settings/_components/danger-zone-settings.tsx new file mode 100644 index 0000000..76c48d4 --- /dev/null +++ b/apps/web/app/reader/settings/_components/danger-zone-settings.tsx @@ -0,0 +1,156 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { useMutation } from "@tanstack/react-query" +import { useUnsubscribeAll } from "@/lib/queries/use-subscription-mutations" +import { useDeleteAllFolders } from "@/lib/queries/use-folder-mutations" +import { notify } from "@/lib/notify" + +export function DangerZoneSettings() { + const router = useRouter() + const unsubscribeAll = useUnsubscribeAll() + const deleteAllFolders = useDeleteAllFolders() + const [showDeleteSubsConfirm, setShowDeleteSubsConfirm] = useState(false) + const [showDeleteFoldersConfirm, setShowDeleteFoldersConfirm] = useState(false) + const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false) + const [deleteConfirmText, setDeleteConfirmText] = useState("") + + const deleteAccount = useMutation({ + mutationFn: async () => { + const response = await fetch("/api/account", { method: "DELETE" }) + if (!response.ok) throw new Error("Failed to delete account") + }, + onSuccess: () => { + router.push("/sign-in") + }, + onError: (error: Error) => { + notify("failed to delete account: " + error.message) + }, + }) + + return ( + <div className="px-4 py-3"> + <p className="mb-6 text-text-dim"> + these actions are irreversible. proceed with caution. + </p> + + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">remove all subscriptions</h3> + <p className="mb-2 text-text-dim"> + unsubscribe from every feed. entries will remain but no new ones will be fetched. + </p> + {showDeleteSubsConfirm ? ( + <div className="flex items-center gap-2"> + <span className="text-status-error">are you sure?</span> + <button + onClick={() => { + unsubscribeAll.mutate() + setShowDeleteSubsConfirm(false) + }} + disabled={unsubscribeAll.isPending} + className="border border-status-error px-3 py-1 text-status-error transition-colors hover:bg-status-error hover:text-background-primary disabled:opacity-50" + > + yes, remove all + </button> + <button + onClick={() => setShowDeleteSubsConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : ( + <button + onClick={() => setShowDeleteSubsConfirm(true)} + className="border border-border px-3 py-1 text-text-secondary transition-colors hover:border-status-error hover:text-status-error" + > + remove all subscriptions + </button> + )} + </div> + + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">delete all folders</h3> + <p className="mb-2 text-text-dim"> + remove all folders. feeds will be ungrouped but not unsubscribed. + </p> + {showDeleteFoldersConfirm ? ( + <div className="flex items-center gap-2"> + <span className="text-status-error">are you sure?</span> + <button + onClick={() => { + deleteAllFolders.mutate() + setShowDeleteFoldersConfirm(false) + }} + disabled={deleteAllFolders.isPending} + className="border border-status-error px-3 py-1 text-status-error transition-colors hover:bg-status-error hover:text-background-primary disabled:opacity-50" + > + yes, delete all + </button> + <button + onClick={() => setShowDeleteFoldersConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : ( + <button + onClick={() => setShowDeleteFoldersConfirm(true)} + className="border border-border px-3 py-1 text-text-secondary transition-colors hover:border-status-error hover:text-status-error" + > + delete all folders + </button> + )} + </div> + + <div> + <h3 className="mb-2 text-text-primary">delete account</h3> + <p className="mb-2 text-text-dim"> + permanently delete your account and all associated data. this cannot be undone. + </p> + {showDeleteAccountConfirm ? ( + <div> + <p className="mb-2 text-status-error"> + type DELETE to confirm account deletion. + </p> + <div className="flex items-center gap-2"> + <input + type="text" + value={deleteConfirmText} + onChange={(event) => setDeleteConfirmText(event.target.value)} + placeholder="type DELETE" + className="border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-status-error" + autoFocus + /> + <button + onClick={() => deleteAccount.mutate()} + disabled={deleteConfirmText !== "DELETE" || deleteAccount.isPending} + className="border border-status-error px-4 py-2 text-status-error transition-colors hover:bg-status-error hover:text-background-primary disabled:opacity-50" + > + {deleteAccount.isPending ? "deleting ..." : "confirm delete"} + </button> + <button + onClick={() => { + setShowDeleteAccountConfirm(false) + setDeleteConfirmText("") + }} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + </div> + ) : ( + <button + onClick={() => setShowDeleteAccountConfirm(true)} + className="border border-border px-3 py-1 text-text-secondary transition-colors hover:border-status-error hover:text-status-error" + > + delete account and all data + </button> + )} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/folders-settings.tsx b/apps/web/app/reader/settings/_components/folders-settings.tsx new file mode 100644 index 0000000..8a0012e --- /dev/null +++ b/apps/web/app/reader/settings/_components/folders-settings.tsx @@ -0,0 +1,220 @@ +"use client" + +import { useState } from "react" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { + useCreateFolder, + useRenameFolder, + useDeleteFolder, +} from "@/lib/queries/use-folder-mutations" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { TIER_LIMITS } from "@asa-news/shared" + +export function FoldersSettings() { + const [newFolderName, setNewFolderName] = useState("") + const [searchQuery, setSearchQuery] = useState("") + const { data: subscriptionsData, isLoading } = useSubscriptions() + const { data: userProfile } = useUserProfile() + const createFolder = useCreateFolder() + const renameFolder = useRenameFolder() + const deleteFolder = useDeleteFolder() + + const folders = subscriptionsData?.folders ?? [] + const subscriptions = subscriptionsData?.subscriptions ?? [] + const tier = userProfile?.tier ?? "free" + const tierLimits = TIER_LIMITS[tier] + + function feedCountForFolder(folderIdentifier: string): number { + return subscriptions.filter( + (subscription) => subscription.folderIdentifier === folderIdentifier + ).length + } + + function handleCreateFolder(event: React.FormEvent) { + event.preventDefault() + const trimmedName = newFolderName.trim() + + if (!trimmedName) return + + createFolder.mutate({ name: trimmedName }) + setNewFolderName("") + } + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading folders ...</p> + } + + const normalizedQuery = searchQuery.toLowerCase().trim() + const filteredFolders = normalizedQuery + ? folders.filter((folder) => folder.name.toLowerCase().includes(normalizedQuery)) + : folders + + return ( + <div> + <div className="border-b border-border px-4 py-3"> + <p className="mb-2 text-text-dim"> + {folders.length} / {tierLimits.maximumFolders} folders used + </p> + <form onSubmit={handleCreateFolder} className="mb-2 flex gap-2"> + <input + type="text" + value={newFolderName} + onChange={(event) => setNewFolderName(event.target.value)} + placeholder="new folder name" + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + type="submit" + disabled={createFolder.isPending || !newFolderName.trim()} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + create + </button> + </form> + {folders.length > 5 && ( + <input + type="text" + value={searchQuery} + onChange={(event) => setSearchQuery(event.target.value)} + placeholder="search folders..." + className="w-full border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + )} + </div> + {filteredFolders.length === 0 ? ( + <p className="px-4 py-6 text-text-dim"> + {folders.length === 0 ? "no folders yet" : "no folders match your search"} + </p> + ) : ( + <div> + {filteredFolders.map((folder) => ( + <FolderRow + key={folder.folderIdentifier} + folderIdentifier={folder.folderIdentifier} + name={folder.name} + feedCount={feedCountForFolder(folder.folderIdentifier)} + onRename={(name) => + renameFolder.mutate({ + folderIdentifier: folder.folderIdentifier, + name, + }) + } + onDelete={() => + deleteFolder.mutate({ + folderIdentifier: folder.folderIdentifier, + }) + } + /> + ))} + </div> + )} + </div> + ) +} + +function FolderRow({ + folderIdentifier, + name, + feedCount, + onRename, + onDelete, +}: { + folderIdentifier: string + name: string + feedCount: number + onRename: (name: string) => void + onDelete: () => void +}) { + const [isEditing, setIsEditing] = useState(false) + const [editedName, setEditedName] = useState(name) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + function handleSave() { + const trimmedName = editedName.trim() + + if (trimmedName && trimmedName !== name) { + onRename(trimmedName) + } + + setIsEditing(false) + } + + return ( + <div className="flex items-center justify-between border-b border-border px-4 py-3 last:border-b-0"> + <div className="min-w-0 flex-1"> + {isEditing ? ( + <div className="flex items-center gap-2"> + <input + type="text" + value={editedName} + onChange={(event) => setEditedName(event.target.value)} + className="min-w-0 flex-1 border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim" + onKeyDown={(event) => { + if (event.key === "Enter") handleSave() + if (event.key === "Escape") setIsEditing(false) + }} + autoFocus + /> + <button + onClick={handleSave} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + onClick={() => { + setEditedName(name) + setIsEditing(false) + }} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : ( + <div className="flex items-center gap-2"> + <span className="text-text-primary">{name}</span> + <span className="text-text-dim"> + ({feedCount} feed{feedCount !== 1 && "s"}) + </span> + <button + onClick={() => setIsEditing(true)} + className="px-2 py-1 text-text-dim transition-colors hover:text-text-secondary" + > + rename + </button> + </div> + )} + </div> + {showDeleteConfirm ? ( + <div className="flex items-center gap-1"> + <span className="text-text-dim"> + {feedCount > 0 ? "has feeds, delete?" : "delete?"} + </span> + <button + onClick={() => { + onDelete() + setShowDeleteConfirm(false) + }} + className="px-2 py-1 text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + onClick={() => setShowDeleteConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + onClick={() => setShowDeleteConfirm(true)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error" + > + delete + </button> + )} + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/import-export-settings.tsx b/apps/web/app/reader/settings/_components/import-export-settings.tsx new file mode 100644 index 0000000..efb3f09 --- /dev/null +++ b/apps/web/app/reader/settings/_components/import-export-settings.tsx @@ -0,0 +1,220 @@ +"use client" + +import { useState, useRef } from "react" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { useSubscribeToFeed } from "@/lib/queries/use-subscribe-to-feed" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { downloadOpml, parseOpml } from "@/lib/opml" +import type { ParsedOpmlGroup } from "@/lib/opml" +import { notify } from "@/lib/notify" + +export function ImportExportSettings() { + const { data: subscriptionsData } = useSubscriptions() + const { data: userProfile } = useUserProfile() + const subscribeToFeed = useSubscribeToFeed() + const [parsedGroups, setParsedGroups] = useState<ParsedOpmlGroup[] | null>( + null + ) + const [isImporting, setIsImporting] = useState(false) + const [isExportingData, setIsExportingData] = useState(false) + const fileInputReference = useRef<HTMLInputElement>(null) + + const tier = userProfile?.tier ?? "free" + + function handleExport() { + if (!subscriptionsData) return + + downloadOpml(subscriptionsData.subscriptions, subscriptionsData.folders) + notify("subscriptions exported") + } + + function handleFileSelect(event: React.ChangeEvent<HTMLInputElement>) { + const file = event.target.files?.[0] + + if (!file) return + + const reader = new FileReader() + reader.onload = (loadEvent) => { + const xmlString = loadEvent.target?.result as string + + try { + const groups = parseOpml(xmlString) + setParsedGroups(groups) + } catch { + notify("failed to parse OPML file") + } + } + reader.readAsText(file) + + if (fileInputReference.current) { + fileInputReference.current.value = "" + } + } + + async function handleImport() { + if (!parsedGroups) return + + setIsImporting(true) + let importedCount = 0 + let failedCount = 0 + + for (const group of parsedGroups) { + for (const feed of group.feeds) { + try { + await new Promise<void>((resolve, reject) => { + subscribeToFeed.mutate( + { + feedUrl: feed.url, + customTitle: feed.title || null, + }, + { + onSuccess: () => { + importedCount++ + resolve() + }, + onError: (error) => { + failedCount++ + resolve() + }, + } + ) + }) + } catch { + failedCount++ + } + } + } + + setIsImporting(false) + setParsedGroups(null) + + if (failedCount > 0) { + notify(`imported ${importedCount} feeds, ${failedCount} failed`) + } else { + notify(`imported ${importedCount} feeds`) + } + } + + async function handleDataExport() { + setIsExportingData(true) + try { + const response = await fetch("/api/export") + if (!response.ok) throw new Error("Export failed") + const blob = await response.blob() + const url = URL.createObjectURL(blob) + const anchor = document.createElement("a") + anchor.href = url + anchor.download = `asa-news-export-${new Date().toISOString().slice(0, 10)}.json` + anchor.click() + URL.revokeObjectURL(url) + notify("data exported") + } catch { + notify("failed to export data") + } finally { + setIsExportingData(false) + } + } + + const totalFeedsInImport = + parsedGroups?.reduce((sum, group) => sum + group.feeds.length, 0) ?? 0 + + return ( + <div className="px-4 py-3"> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">export OPML</h3> + <p className="mb-3 text-text-dim"> + download your subscriptions as an OPML file + </p> + <button + onClick={handleExport} + disabled={!subscriptionsData} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + export OPML + </button> + </div> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">export data</h3> + <p className="mb-3 text-text-dim"> + {tier === "pro" || tier === "developer" + ? "download all your data as JSON (subscriptions, folders, saved entries)" + : "download your saved entries as JSON (upgrade to pro for full export)"} + </p> + <button + onClick={handleDataExport} + disabled={isExportingData} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isExportingData ? "exporting..." : "export data"} + </button> + </div> + <div> + <h3 className="mb-2 text-text-primary">import</h3> + <p className="mb-3 text-text-dim"> + import subscriptions from an OPML file + </p> + {parsedGroups === null ? ( + <div> + <input + ref={fileInputReference} + type="file" + accept=".opml,.xml" + onChange={handleFileSelect} + className="hidden" + /> + <button + onClick={() => fileInputReference.current?.click()} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border" + > + select OPML file + </button> + </div> + ) : ( + <div> + <p className="mb-3 text-text-secondary"> + found {totalFeedsInImport} feed + {totalFeedsInImport !== 1 && "s"} to import: + </p> + <div className="mb-3 max-h-60 overflow-y-auto border border-border"> + {parsedGroups.map((group, groupIndex) => ( + <div key={groupIndex}> + {group.folderName && ( + <div className="bg-background-tertiary px-3 py-1 text-text-secondary"> + {group.folderName} + </div> + )} + {group.feeds.map((feed, feedIndex) => ( + <div + key={feedIndex} + className="border-b border-border px-3 py-2 last:border-b-0" + > + <p className="truncate text-text-primary">{feed.title}</p> + <p className="truncate text-text-dim">{feed.url}</p> + </div> + ))} + </div> + ))} + </div> + <div className="flex gap-2"> + <button + onClick={() => setParsedGroups(null)} + className="border border-border px-4 py-2 text-text-secondary transition-colors hover:bg-background-tertiary hover:text-text-primary" + > + cancel + </button> + <button + onClick={handleImport} + disabled={isImporting} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isImporting + ? "importing..." + : `import ${totalFeedsInImport} feeds`} + </button> + </div> + </div> + )} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/muted-keywords-settings.tsx b/apps/web/app/reader/settings/_components/muted-keywords-settings.tsx new file mode 100644 index 0000000..bef4786 --- /dev/null +++ b/apps/web/app/reader/settings/_components/muted-keywords-settings.tsx @@ -0,0 +1,89 @@ +"use client" + +import { useState } from "react" +import { useMutedKeywords } from "@/lib/queries/use-muted-keywords" +import { + useAddMutedKeyword, + useDeleteMutedKeyword, +} from "@/lib/queries/use-muted-keyword-mutations" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { TIER_LIMITS } from "@asa-news/shared" + +export function MutedKeywordsSettings() { + const [newKeyword, setNewKeyword] = useState("") + const { data: keywords, isLoading } = useMutedKeywords() + const { data: userProfile } = useUserProfile() + const addKeyword = useAddMutedKeyword() + const deleteKeyword = useDeleteMutedKeyword() + + const tier = userProfile?.tier ?? "free" + const tierLimits = TIER_LIMITS[tier] + + function handleAddKeyword(event: React.FormEvent) { + event.preventDefault() + const trimmedKeyword = newKeyword.trim() + + if (!trimmedKeyword) return + + addKeyword.mutate({ keyword: trimmedKeyword }) + setNewKeyword("") + } + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading muted keywords...</p> + } + + const keywordList = keywords ?? [] + + return ( + <div> + <div className="border-b border-border px-4 py-3"> + <p className="mb-1 text-text-dim"> + {keywordList.length} / {tierLimits.maximumMutedKeywords} keywords used + </p> + <p className="mb-2 text-text-dim"> + entries containing muted keywords are hidden from your timeline + </p> + <form onSubmit={handleAddKeyword} className="flex gap-2"> + <input + type="text" + value={newKeyword} + onChange={(event) => setNewKeyword(event.target.value)} + placeholder="keyword to mute" + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + type="submit" + disabled={addKeyword.isPending || !newKeyword.trim()} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + mute + </button> + </form> + </div> + {keywordList.length === 0 ? ( + <p className="px-4 py-6 text-text-dim">no muted keywords</p> + ) : ( + keywordList.map((keyword) => ( + <div + key={keyword.identifier} + className="flex items-center justify-between border-b border-border px-4 py-3 last:border-b-0" + > + <span className="text-text-primary">{keyword.keyword}</span> + <button + onClick={() => + deleteKeyword.mutate({ + keywordIdentifier: keyword.identifier, + }) + } + disabled={deleteKeyword.isPending} + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error disabled:opacity-50" + > + unmute + </button> + </div> + )) + )} + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/security-settings.tsx b/apps/web/app/reader/settings/_components/security-settings.tsx new file mode 100644 index 0000000..4a00241 --- /dev/null +++ b/apps/web/app/reader/settings/_components/security-settings.tsx @@ -0,0 +1,280 @@ +"use client" + +import { useState, useEffect } from "react" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { notify } from "@/lib/notify" +import type { Factor } from "@supabase/supabase-js" + +type EnrollmentState = + | { step: "idle" } + | { step: "enrolling"; factorIdentifier: string; qrCodeSvg: string; otpauthUri: string } + | { step: "verifying"; factorIdentifier: string; challengeIdentifier: string } + +export function SecuritySettings() { + const [enrolledFactors, setEnrolledFactors] = useState<Factor[]>([]) + const [isLoading, setIsLoading] = useState(true) + const [enrollmentState, setEnrollmentState] = useState<EnrollmentState>({ step: "idle" }) + const [factorName, setFactorName] = useState("") + const [verificationCode, setVerificationCode] = useState("") + const [isProcessing, setIsProcessing] = useState(false) + const [unenrollConfirmIdentifier, setUnenrollConfirmIdentifier] = useState<string | null>(null) + const supabaseClient = createSupabaseBrowserClient() + + async function loadFactors() { + const { data, error } = await supabaseClient.auth.mfa.listFactors() + + if (error) { + notify("failed to load MFA factors") + setIsLoading(false) + return + } + + setEnrolledFactors( + data.totp.filter((factor) => factor.status === "verified") + ) + setIsLoading(false) + } + + useEffect(() => { + loadFactors() + }, []) + + async function handleBeginEnrollment() { + setIsProcessing(true) + + const enrollOptions: { factorType: "totp"; friendlyName?: string } = { + factorType: "totp", + } + if (factorName.trim()) { + enrollOptions.friendlyName = factorName.trim() + } + + const { data, error } = await supabaseClient.auth.mfa.enroll(enrollOptions) + + setIsProcessing(false) + + if (error) { + notify("failed to start MFA enrolment: " + error.message) + return + } + + setEnrollmentState({ + step: "enrolling", + factorIdentifier: data.id, + qrCodeSvg: data.totp.qr_code, + otpauthUri: data.totp.uri, + }) + setVerificationCode("") + } + + async function handleVerifyEnrollment() { + if (enrollmentState.step !== "enrolling") return + if (verificationCode.length !== 6) return + + setIsProcessing(true) + + const { data: challengeData, error: challengeError } = + await supabaseClient.auth.mfa.challenge({ + factorId: enrollmentState.factorIdentifier, + }) + + if (challengeError) { + setIsProcessing(false) + notify("failed to create MFA challenge: " + challengeError.message) + return + } + + const { error: verifyError } = await supabaseClient.auth.mfa.verify({ + factorId: enrollmentState.factorIdentifier, + challengeId: challengeData.id, + code: verificationCode, + }) + + setIsProcessing(false) + + if (verifyError) { + notify("invalid code — please try again") + setVerificationCode("") + return + } + + notify("two-factor authentication enabled") + setEnrollmentState({ step: "idle" }) + setVerificationCode("") + setFactorName("") + await supabaseClient.auth.refreshSession() + await loadFactors() + } + + async function handleCancelEnrollment() { + if (enrollmentState.step === "enrolling") { + await supabaseClient.auth.mfa.unenroll({ + factorId: enrollmentState.factorIdentifier, + }) + } + + setEnrollmentState({ step: "idle" }) + setVerificationCode("") + setFactorName("") + } + + async function handleUnenrollFactor(factorIdentifier: string) { + setIsProcessing(true) + + const { error } = await supabaseClient.auth.mfa.unenroll({ + factorId: factorIdentifier, + }) + + setIsProcessing(false) + + if (error) { + notify("failed to remove factor: " + error.message) + return + } + + notify("two-factor authentication removed") + setUnenrollConfirmIdentifier(null) + await supabaseClient.auth.refreshSession() + await loadFactors() + } + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading security settings ...</p> + } + + return ( + <div className="px-4 py-3"> + <div className="mb-6"> + <h3 className="mb-2 text-text-primary">two-factor authentication</h3> + <p className="mb-4 text-text-dim"> + add an extra layer of security to your account with a time-based one-time password (TOTP) authenticator app + </p> + + {enrollmentState.step === "idle" && enrolledFactors.length === 0 && ( + <div className="flex items-center gap-2"> + <input + type="text" + value={factorName} + onChange={(event) => setFactorName(event.target.value)} + placeholder="authenticator name (optional)" + className="w-64 border border-border bg-background-primary px-3 py-2 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <button + onClick={handleBeginEnrollment} + disabled={isProcessing} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isProcessing ? "setting up ..." : "set up"} + </button> + </div> + )} + + {enrollmentState.step === "enrolling" && ( + <div className="space-y-4"> + <p className="text-text-secondary"> + scan this QR code with your authenticator app, then enter the 6-digit code below + </p> + <div className="inline-block bg-white p-4"> + <img + src={enrollmentState.qrCodeSvg} + alt="TOTP QR code" + className="h-48 w-48" + /> + </div> + <details className="text-text-dim"> + <summary className="cursor-pointer transition-colors hover:text-text-secondary"> + can't scan? copy manual entry key + </summary> + <code className="mt-2 block break-all bg-background-secondary p-2 text-text-secondary"> + {enrollmentState.otpauthUri} + </code> + </details> + <div className="flex items-center gap-2"> + <input + type="text" + inputMode="numeric" + pattern="[0-9]*" + maxLength={6} + value={verificationCode} + onChange={(event) => { + const filtered = event.target.value.replace(/\D/g, "") + setVerificationCode(filtered) + }} + placeholder="000000" + className="w-32 border border-border bg-background-primary px-3 py-2 text-center font-mono text-lg tracking-widest text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + autoFocus + onKeyDown={(event) => { + if (event.key === "Enter") handleVerifyEnrollment() + if (event.key === "Escape") handleCancelEnrollment() + }} + /> + <button + onClick={handleVerifyEnrollment} + disabled={isProcessing || verificationCode.length !== 6} + className="border border-border bg-background-tertiary px-4 py-2 text-text-primary transition-colors hover:bg-border disabled:opacity-50" + > + {isProcessing ? "verifying ..." : "verify"} + </button> + <button + onClick={handleCancelEnrollment} + className="px-4 py-2 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + </div> + )} + + {enrolledFactors.length > 0 && enrollmentState.step === "idle" && ( + <div className="space-y-3"> + {enrolledFactors.map((factor) => ( + <div + key={factor.id} + className="flex items-center justify-between border border-border px-4 py-3" + > + <div> + <span className="text-text-primary"> + {factor.friendly_name || "TOTP authenticator"} + </span> + <span className="ml-2 text-text-dim"> + added{" "} + {new Date(factor.created_at).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + })} + </span> + </div> + {unenrollConfirmIdentifier === factor.id ? ( + <div className="flex items-center gap-2"> + <span className="text-text-dim">remove?</span> + <button + onClick={() => handleUnenrollFactor(factor.id)} + disabled={isProcessing} + className="text-status-error transition-colors hover:text-text-primary disabled:opacity-50" + > + yes + </button> + <button + onClick={() => setUnenrollConfirmIdentifier(null)} + className="text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + onClick={() => setUnenrollConfirmIdentifier(factor.id)} + className="text-text-secondary transition-colors hover:text-status-error" + > + remove + </button> + )} + </div> + ))} + </div> + )} + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/settings-shell.tsx b/apps/web/app/reader/settings/_components/settings-shell.tsx new file mode 100644 index 0000000..ae432f3 --- /dev/null +++ b/apps/web/app/reader/settings/_components/settings-shell.tsx @@ -0,0 +1,86 @@ +"use client" + +import { useUserInterfaceStore } from "@/lib/stores/user-interface-store" +import { SubscriptionsSettings } from "./subscriptions-settings" +import { FoldersSettings } from "./folders-settings" +import { MutedKeywordsSettings } from "./muted-keywords-settings" +import { CustomFeedsSettings } from "./custom-feeds-settings" +import { ImportExportSettings } from "./import-export-settings" +import { AppearanceSettings } from "./appearance-settings" +import { AccountSettings } from "./account-settings" +import { SecuritySettings } from "./security-settings" +import { BillingSettings } from "./billing-settings" +import { ApiSettings } from "./api-settings" +import { DangerZoneSettings } from "./danger-zone-settings" + +const TABS = [ + { key: "subscriptions", label: "subscriptions" }, + { key: "folders", label: "folders" }, + { key: "muted-keywords", label: "muted keywords" }, + { key: "custom-feeds", label: "custom feeds" }, + { key: "import-export", label: "import / export" }, + { key: "appearance", label: "appearance" }, + { key: "account", label: "account" }, + { key: "security", label: "security" }, + { key: "billing", label: "billing" }, + { key: "api", label: "API" }, + { key: "danger", label: "danger zone" }, +] as const + +export function SettingsShell() { + const activeTab = useUserInterfaceStore((state) => state.activeSettingsTab) + const setActiveTab = useUserInterfaceStore( + (state) => state.setActiveSettingsTab + ) + + return ( + <div className="flex h-full flex-col"> + <header className="flex items-center border-b border-border px-4 py-3"> + <h1 className="text-text-primary">settings</h1> + </header> + <nav className="border-b border-border"> + <select + value={activeTab} + onChange={(event) => setActiveTab(event.target.value as typeof activeTab)} + className="w-full border-none bg-background-primary px-4 py-2 text-text-primary outline-none md:hidden" + > + {TABS.map((tab) => ( + <option key={tab.key} value={tab.key}> + {tab.label} + </option> + ))} + </select> + <div className="hidden md:flex"> + {TABS.map((tab) => ( + <button + key={tab.key} + onClick={() => setActiveTab(tab.key)} + className={`shrink-0 px-4 py-2 transition-colors ${ + activeTab === tab.key + ? "border-b-2 border-text-primary text-text-primary" + : "text-text-dim hover:text-text-secondary" + }`} + > + {tab.label} + </button> + ))} + </div> + </nav> + <div className="flex-1 overflow-y-auto"> + <div className="max-w-3xl"> + {activeTab === "subscriptions" && <SubscriptionsSettings />} + {activeTab === "folders" && <FoldersSettings />} + {activeTab === "muted-keywords" && <MutedKeywordsSettings />} + {activeTab === "custom-feeds" && <CustomFeedsSettings />} + {activeTab === "import-export" && <ImportExportSettings />} + {activeTab === "appearance" && <AppearanceSettings />} + {activeTab === "account" && <AccountSettings />} + {activeTab === "security" && <SecuritySettings />} + {activeTab === "billing" && <BillingSettings />} + {activeTab === "api" && <ApiSettings />} + {activeTab === "danger" && <DangerZoneSettings />} + </div> + </div> + </div> + ) +} diff --git a/apps/web/app/reader/settings/_components/subscriptions-settings.tsx b/apps/web/app/reader/settings/_components/subscriptions-settings.tsx new file mode 100644 index 0000000..7257231 --- /dev/null +++ b/apps/web/app/reader/settings/_components/subscriptions-settings.tsx @@ -0,0 +1,281 @@ +"use client" + +import { useState } from "react" +import { useSubscriptions } from "@/lib/queries/use-subscriptions" +import { + useUpdateSubscriptionTitle, + useMoveSubscriptionToFolder, + useUnsubscribe, + useRequestFeedRefresh, +} from "@/lib/queries/use-subscription-mutations" +import { useUserProfile } from "@/lib/queries/use-user-profile" +import { TIER_LIMITS } from "@asa-news/shared" +import type { Subscription } from "@/lib/types/subscription" + +function formatRelativeTime(isoString: string | null): string { + if (!isoString) return "never" + const date = new Date(isoString) + const now = new Date() + const differenceSeconds = Math.floor((now.getTime() - date.getTime()) / 1000) + if (differenceSeconds < 60) return "just now" + if (differenceSeconds < 3600) return `${Math.floor(differenceSeconds / 60)}m ago` + if (differenceSeconds < 86400) return `${Math.floor(differenceSeconds / 3600)}h ago` + return `${Math.floor(differenceSeconds / 86400)}d ago` +} + +function formatRefreshInterval(seconds: number): string { + if (seconds < 3600) return `${Math.round(seconds / 60)} min` + return `${Math.round(seconds / 3600)} hr` +} + +function SubscriptionRow({ + subscription, + folderOptions, +}: { + subscription: Subscription + folderOptions: { identifier: string; name: string }[] +}) { + const [isEditingTitle, setIsEditingTitle] = useState(false) + const [editedTitle, setEditedTitle] = useState( + subscription.customTitle ?? "" + ) + const [showUnsubscribeConfirm, setShowUnsubscribeConfirm] = useState(false) + const updateTitle = useUpdateSubscriptionTitle() + const moveToFolder = useMoveSubscriptionToFolder() + const unsubscribe = useUnsubscribe() + const requestRefresh = useRequestFeedRefresh() + const { data: userProfile } = useUserProfile() + + function handleSaveTitle() { + const trimmedTitle = editedTitle.trim() + updateTitle.mutate({ + subscriptionIdentifier: subscription.subscriptionIdentifier, + customTitle: trimmedTitle || null, + }) + setIsEditingTitle(false) + } + + function handleFolderChange(folderIdentifier: string) { + const sourceFolder = folderOptions.find( + (folder) => folder.identifier === subscription.folderIdentifier + ) + const targetFolder = folderOptions.find( + (folder) => folder.identifier === folderIdentifier + ) + moveToFolder.mutate({ + subscriptionIdentifier: subscription.subscriptionIdentifier, + folderIdentifier: folderIdentifier || null, + feedTitle: subscription.customTitle ?? subscription.feedTitle ?? undefined, + sourceFolderName: sourceFolder?.name, + folderName: targetFolder?.name, + }) + } + + return ( + <div className="flex flex-col gap-2 border-b border-border px-4 py-3 last:border-b-0"> + <div className="flex items-center justify-between gap-4"> + <div className="min-w-0 flex-1"> + {isEditingTitle ? ( + <div className="flex items-center gap-2"> + <input + type="text" + value={editedTitle} + onChange={(event) => setEditedTitle(event.target.value)} + placeholder={subscription.feedTitle} + className="min-w-0 flex-1 border border-border bg-background-primary px-2 py-1 text-text-primary outline-none focus:border-text-dim" + onKeyDown={(event) => { + if (event.key === "Enter") handleSaveTitle() + if (event.key === "Escape") setIsEditingTitle(false) + }} + autoFocus + /> + <button + onClick={handleSaveTitle} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + save + </button> + <button + onClick={() => setIsEditingTitle(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + cancel + </button> + </div> + ) : ( + <div className="flex items-center gap-2"> + <span className="truncate text-text-primary"> + {subscription.customTitle ?? subscription.feedTitle} + </span> + <button + onClick={() => { + setEditedTitle(subscription.customTitle ?? "") + setIsEditingTitle(true) + }} + className="shrink-0 px-2 py-1 text-text-dim transition-colors hover:text-text-secondary" + > + rename + </button> + </div> + )} + <p className="truncate text-text-dim">{subscription.feedUrl}</p> + <div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-text-dim"> + <span>last fetched: {formatRelativeTime(subscription.lastFetchedAt)}</span> + <span>interval: {formatRefreshInterval(subscription.fetchIntervalSeconds)}</span> + {subscription.consecutiveFailures > 0 && ( + <span className="text-status-warning"> + {subscription.consecutiveFailures} consecutive failure{subscription.consecutiveFailures !== 1 && "s"} + </span> + )} + </div> + {subscription.lastFetchError && subscription.consecutiveFailures > 0 && ( + <p className="mt-1 truncate text-status-warning"> + {subscription.lastFetchError} + </p> + )} + </div> + </div> + <div className="flex items-center gap-2"> + <select + value={subscription.folderIdentifier ?? ""} + onChange={(event) => handleFolderChange(event.target.value)} + className="border border-border bg-background-primary px-2 py-1 text-text-secondary outline-none" + > + <option value="">no folder</option> + {folderOptions.map((folder) => ( + <option key={folder.identifier} value={folder.identifier}> + {folder.name} + </option> + ))} + </select> + {(userProfile?.tier === "pro" || userProfile?.tier === "developer") && ( + <button + onClick={() => + requestRefresh.mutate({ + subscriptionIdentifier: + subscription.subscriptionIdentifier, + }) + } + disabled={requestRefresh.isPending} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary disabled:opacity-50" + > + refresh + </button> + )} + {showUnsubscribeConfirm ? ( + <div className="flex items-center gap-1"> + <span className="text-text-dim">confirm?</span> + <button + onClick={() => { + unsubscribe.mutate({ + subscriptionIdentifier: + subscription.subscriptionIdentifier, + }) + setShowUnsubscribeConfirm(false) + }} + className="px-2 py-1 text-status-error transition-colors hover:text-text-primary" + > + yes + </button> + <button + onClick={() => setShowUnsubscribeConfirm(false)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-text-primary" + > + no + </button> + </div> + ) : ( + <button + onClick={() => setShowUnsubscribeConfirm(true)} + className="px-2 py-1 text-text-secondary transition-colors hover:text-status-error" + > + unsubscribe + </button> + )} + </div> + </div> + ) +} + +export function SubscriptionsSettings() { + const { data: subscriptionsData, isLoading } = useSubscriptions() + const { data: userProfile } = useUserProfile() + const [searchQuery, setSearchQuery] = useState("") + const [folderFilter, setFolderFilter] = useState<string>("all") + + if (isLoading) { + return <p className="px-4 py-6 text-text-dim">loading subscriptions ...</p> + } + + const subscriptions = subscriptionsData?.subscriptions ?? [] + const folders = subscriptionsData?.folders ?? [] + const folderOptions = folders.map((folder) => ({ + identifier: folder.folderIdentifier, + name: folder.name, + })) + + if (subscriptions.length === 0) { + return ( + <p className="px-4 py-6 text-text-dim"> + no subscriptions yet — add a feed to get started + </p> + ) + } + + const normalizedQuery = searchQuery.toLowerCase().trim() + + const filteredSubscriptions = subscriptions.filter((subscription) => { + if (folderFilter === "ungrouped" && subscription.folderIdentifier !== null) return false + if (folderFilter !== "all" && folderFilter !== "ungrouped" && subscription.folderIdentifier !== folderFilter) return false + + if (normalizedQuery) { + const title = (subscription.customTitle ?? subscription.feedTitle ?? "").toLowerCase() + const url = (subscription.feedUrl ?? "").toLowerCase() + if (!title.includes(normalizedQuery) && !url.includes(normalizedQuery)) return false + } + + return true + }) + + return ( + <div> + <div className="flex flex-wrap items-center gap-2 px-4 py-3"> + <input + type="text" + value={searchQuery} + onChange={(event) => setSearchQuery(event.target.value)} + placeholder="search subscriptions..." + className="min-w-0 flex-1 border border-border bg-background-primary px-3 py-1.5 text-text-primary outline-none placeholder:text-text-dim focus:border-text-dim" + /> + <select + value={folderFilter} + onChange={(event) => setFolderFilter(event.target.value)} + className="border border-border bg-background-primary px-2 py-1.5 text-text-secondary outline-none" + > + <option value="all">all folders</option> + <option value="ungrouped">ungrouped</option> + {folderOptions.map((folder) => ( + <option key={folder.identifier} value={folder.identifier}> + {folder.name} + </option> + ))} + </select> + <span className="text-text-dim"> + {filteredSubscriptions.length} / {TIER_LIMITS[userProfile?.tier ?? "free"].maximumFeeds} + </span> + </div> + <div> + {filteredSubscriptions.map((subscription) => ( + <SubscriptionRow + key={subscription.subscriptionIdentifier} + subscription={subscription} + folderOptions={folderOptions} + /> + ))} + {filteredSubscriptions.length === 0 && ( + <p className="px-4 py-6 text-text-dim">no subscriptions match your filters</p> + )} + </div> + </div> + ) +} |