mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 02:45:39 -04:00
Refactor auth session handling and server actions across app
- Overhaul `lib/auth/server.ts` and `lib/auth/session.ts`; update all API routes and server actions to use the revised session pattern - Refactor server actions (settings, titles, watchlist, setup) for consistency with new auth layer - Extract `SetupForm` into its own client component with `useActionState`, animated steps, and copyable env snippets - Move landing page redirect logic into `app/page.tsx`; slim down `LandingPage` component - Add `proxy.ts` for local dev proxying - Minor cleanup to `NavBar`, `MobileTabBar`, and `TitleCard`
This commit is contained in:
+13
-26
@@ -1,9 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { requireAdmin, requireSession } from "@/lib/auth/session";
|
||||
import { type BackupFrequency, rescheduleBackup } from "@/lib/cron";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { integrations } from "@/lib/db/schema";
|
||||
@@ -23,18 +22,6 @@ function integrationTypeFor(provider: string): "webhook" | "list" {
|
||||
return LIST_PROVIDERS.has(provider) ? "list" : "webhook";
|
||||
}
|
||||
|
||||
async function getSession() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) throw new Error("Unauthorized");
|
||||
return session;
|
||||
}
|
||||
|
||||
async function getAdminSession() {
|
||||
const session = await getSession();
|
||||
if (session.user.role !== "admin") throw new Error("Forbidden");
|
||||
return session;
|
||||
}
|
||||
|
||||
function generateToken() {
|
||||
return Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString(
|
||||
"hex",
|
||||
@@ -44,7 +31,7 @@ function generateToken() {
|
||||
// --- Integration actions ---
|
||||
|
||||
export async function saveIntegration(provider: string, enabled?: boolean) {
|
||||
const session = await getSession();
|
||||
const session = await requireSession();
|
||||
const parsed = providerSchema.parse(provider);
|
||||
|
||||
const existing = db
|
||||
@@ -95,7 +82,7 @@ export async function saveIntegration(provider: string, enabled?: boolean) {
|
||||
}
|
||||
|
||||
export async function deleteIntegration(provider: string) {
|
||||
const session = await getSession();
|
||||
const session = await requireSession();
|
||||
const parsed = providerSchema.parse(provider);
|
||||
|
||||
db.delete(integrations)
|
||||
@@ -109,7 +96,7 @@ export async function deleteIntegration(provider: string) {
|
||||
}
|
||||
|
||||
export async function regenerateIntegrationToken(provider: string) {
|
||||
const session = await getSession();
|
||||
const session = await requireSession();
|
||||
const parsed = providerSchema.parse(provider);
|
||||
|
||||
const row = db
|
||||
@@ -136,36 +123,36 @@ export async function regenerateIntegrationToken(provider: string) {
|
||||
// --- Admin actions ---
|
||||
|
||||
export async function toggleRegistration(open: boolean) {
|
||||
await getAdminSession();
|
||||
await requireAdmin();
|
||||
setSetting("registrationOpen", String(open));
|
||||
}
|
||||
|
||||
export async function toggleUpdateCheck(enabled: boolean) {
|
||||
await getAdminSession();
|
||||
await requireAdmin();
|
||||
setSetting("updateCheckEnabled", String(enabled));
|
||||
}
|
||||
|
||||
// --- Backup actions ---
|
||||
|
||||
export async function createBackupAction(): Promise<BackupInfo> {
|
||||
await getAdminSession();
|
||||
await requireAdmin();
|
||||
return await createBackup();
|
||||
}
|
||||
|
||||
export async function listBackupsAction(): Promise<BackupInfo[]> {
|
||||
await getAdminSession();
|
||||
await requireAdmin();
|
||||
return await listBackups();
|
||||
}
|
||||
|
||||
export async function deleteBackupAction(filename: string): Promise<void> {
|
||||
await getAdminSession();
|
||||
await requireAdmin();
|
||||
await deleteBackup(filename);
|
||||
}
|
||||
|
||||
export async function setScheduledBackupAction(
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
await getAdminSession();
|
||||
await requireAdmin();
|
||||
setSetting("scheduledBackups", String(enabled));
|
||||
}
|
||||
|
||||
@@ -173,7 +160,7 @@ export async function getScheduledBackupSettings(): Promise<{
|
||||
enabled: boolean;
|
||||
maxRetention: number;
|
||||
}> {
|
||||
await getAdminSession();
|
||||
await requireAdmin();
|
||||
return {
|
||||
enabled: getSetting("scheduledBackups") === "true",
|
||||
maxRetention: Number.parseInt(getSetting("maxBackupRetention") ?? "7", 10),
|
||||
@@ -188,7 +175,7 @@ const maxBackupsSchema = z
|
||||
});
|
||||
|
||||
export async function setMaxBackupsAction(max: number): Promise<void> {
|
||||
await getAdminSession();
|
||||
await requireAdmin();
|
||||
maxBackupsSchema.parse(max);
|
||||
setSetting("maxBackupRetention", String(max));
|
||||
}
|
||||
@@ -213,7 +200,7 @@ export async function setBackupScheduleAction(
|
||||
time: string,
|
||||
dayOfWeek = 0,
|
||||
): Promise<void> {
|
||||
await getAdminSession();
|
||||
await requireAdmin();
|
||||
const parsed = backupScheduleSchema.parse({ frequency, time, dayOfWeek });
|
||||
setSetting("backupScheduleFrequency", parsed.frequency);
|
||||
setSetting("backupScheduleTime", parsed.time);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { isTmdbConfigured } from "@/lib/config";
|
||||
|
||||
export async function checkTmdbConfigured() {
|
||||
return isTmdbConfigured();
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { requireSession } from "@/lib/auth/session";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { episodes } from "@/lib/db/schema";
|
||||
import {
|
||||
@@ -19,8 +18,7 @@ import {
|
||||
} from "@/lib/services/tracking";
|
||||
|
||||
async function getSessionUserId() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) throw new Error("Unauthorized");
|
||||
const session = await requireSession();
|
||||
return session.user.id;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { getSession, requireSession } from "@/lib/auth/session";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { userTitleStatus } from "@/lib/db/schema";
|
||||
import { importTitle } from "@/lib/services/metadata";
|
||||
@@ -15,7 +14,7 @@ import {
|
||||
export async function fetchUserStatuses(
|
||||
tmdbIds: { tmdbId: number; type: string }[],
|
||||
): Promise<Record<string, "watchlist" | "in_progress" | "completed">> {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const session = await getSession();
|
||||
if (!session) return {};
|
||||
return getUserStatusesByTmdbIds(session.user.id, tmdbIds);
|
||||
}
|
||||
@@ -23,7 +22,7 @@ export async function fetchUserStatuses(
|
||||
export async function fetchEpisodeProgress(
|
||||
tmdbIds: { tmdbId: number; type: string }[],
|
||||
): Promise<Record<string, { watched: number; total: number }>> {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
const session = await getSession();
|
||||
if (!session) return {};
|
||||
return getEpisodeProgressByTmdbIds(session.user.id, tmdbIds);
|
||||
}
|
||||
@@ -32,8 +31,7 @@ export async function quickAddToWatchlist(
|
||||
tmdbId: number,
|
||||
type: "movie" | "tv",
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) throw new Error("Unauthorized");
|
||||
const session = await requireSession();
|
||||
const userId = session.user.id;
|
||||
|
||||
const title = await importTitle(tmdbId, type);
|
||||
|
||||
+43
-31
@@ -1,6 +1,7 @@
|
||||
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
|
||||
import { APIError, createAuthMiddleware } from "better-auth/api";
|
||||
import { betterAuth } from "better-auth/minimal";
|
||||
import { type BetterAuthOptions, betterAuth } from "better-auth/minimal";
|
||||
import { nextCookies } from "better-auth/next-js";
|
||||
import { admin, genericOAuth } from "better-auth/plugins";
|
||||
import {
|
||||
isOidcAutoRegisterEnabled,
|
||||
@@ -15,27 +16,6 @@ import {
|
||||
setSetting,
|
||||
} from "@/lib/services/settings";
|
||||
|
||||
const oidcPlugin = isOidcConfigured()
|
||||
? [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: "oidc",
|
||||
clientId: process.env.OIDC_CLIENT_ID ?? "",
|
||||
clientSecret: process.env.OIDC_CLIENT_SECRET ?? "",
|
||||
discoveryUrl: `${process.env.OIDC_ISSUER_URL}/.well-known/openid-configuration`,
|
||||
scopes: ["openid", "email", "profile"],
|
||||
pkce: true,
|
||||
disableImplicitSignUp: !isOidcAutoRegisterEnabled(),
|
||||
mapProfileToUser: (profile) => ({
|
||||
name: profile.name || profile.preferred_username || profile.email,
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
|
||||
const authLog = createLogger("auth");
|
||||
|
||||
export const auth = betterAuth({
|
||||
@@ -60,7 +40,37 @@ export const auth = betterAuth({
|
||||
trustedProviders: ["oidc"],
|
||||
},
|
||||
},
|
||||
plugins: [admin(), ...oidcPlugin],
|
||||
session: {
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60,
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
admin(),
|
||||
...(isOidcConfigured()
|
||||
? [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: "oidc",
|
||||
clientId: process.env.OIDC_CLIENT_ID ?? "",
|
||||
clientSecret: process.env.OIDC_CLIENT_SECRET ?? "",
|
||||
discoveryUrl: `${process.env.OIDC_ISSUER_URL}/.well-known/openid-configuration`,
|
||||
scopes: ["openid", "email", "profile"],
|
||||
pkce: true,
|
||||
disableImplicitSignUp: !isOidcAutoRegisterEnabled(),
|
||||
mapProfileToUser: (profile) => ({
|
||||
name:
|
||||
profile.name || profile.preferred_username || profile.email,
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
nextCookies(), // must be last
|
||||
],
|
||||
advanced: {
|
||||
database: {
|
||||
generateId: () => Bun.randomUUIDv7(),
|
||||
@@ -72,7 +82,7 @@ export const auth = betterAuth({
|
||||
// This is endpoint-level so it doesn't affect OIDC user creation
|
||||
// (which is gated by the genericOAuth plugin's disableImplicitSignUp).
|
||||
if (ctx.path === "/sign-up/email") {
|
||||
const open = await isRegistrationOpen();
|
||||
const open = isRegistrationOpen();
|
||||
if (!open) {
|
||||
throw new APIError("FORBIDDEN", {
|
||||
message: "Registration is currently closed",
|
||||
@@ -84,27 +94,29 @@ export const auth = betterAuth({
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
before: async (userData) => {
|
||||
before: async (user) => {
|
||||
// First user becomes admin regardless of auth method
|
||||
const userCount = await getUserCount();
|
||||
const userCount = getUserCount();
|
||||
if (userCount === 0) {
|
||||
return {
|
||||
data: {
|
||||
...userData,
|
||||
...user,
|
||||
role: "admin",
|
||||
},
|
||||
};
|
||||
}
|
||||
return { data: userData };
|
||||
return { data: user };
|
||||
},
|
||||
after: async () => {
|
||||
// Auto-close registration after first user
|
||||
const userCount = await getUserCount();
|
||||
const userCount = getUserCount();
|
||||
if (userCount === 1) {
|
||||
await setSetting("registrationOpen", "false");
|
||||
setSetting("registrationOpen", "false");
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} satisfies BetterAuthOptions);
|
||||
|
||||
export type Session = typeof auth.$Infer.Session;
|
||||
|
||||
+14
-2
@@ -5,9 +5,21 @@ import { auth } from "./server";
|
||||
/**
|
||||
* Cached session getter — deduplicated per request via React.cache().
|
||||
* Use this instead of calling auth.api.getSession() directly in server
|
||||
* components and layouts to avoid redundant session lookups within a
|
||||
* single render pass.
|
||||
* components, route handlers, and server actions to avoid redundant
|
||||
* session lookups within a single render pass.
|
||||
*/
|
||||
export const getSession = cache(async () => {
|
||||
return auth.api.getSession({ headers: await headers() });
|
||||
});
|
||||
|
||||
export async function requireSession() {
|
||||
const session = await getSession();
|
||||
if (!session) throw new Error("Unauthorized");
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function requireAdmin() {
|
||||
const session = await requireSession();
|
||||
if (session.user.role !== "admin") throw new Error("Forbidden");
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -14,8 +14,9 @@ import {
|
||||
} from "./lists";
|
||||
|
||||
// Mock getTvExternalIds for lazy resolution tests
|
||||
const mockGetTvExternalIds = mock(() =>
|
||||
Promise.resolve({ tvdb_id: 55555, imdb_id: "tt9999999" }),
|
||||
const mockGetTvExternalIds = mock(
|
||||
(): Promise<{ tvdb_id: number | null; imdb_id: string | null }> =>
|
||||
Promise.resolve({ tvdb_id: 55555, imdb_id: "tt9999999" }),
|
||||
);
|
||||
mock.module("@/lib/tmdb/client", () => ({
|
||||
getTvExternalIds: mockGetTvExternalIds,
|
||||
|
||||
Reference in New Issue
Block a user