diff --git a/messages/en-US.json b/messages/en-US.json index 178a9bb9..0a45b32e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -150,6 +150,7 @@ "resourceAdd": "Add Resource", "resourceErrorDelte": "Error deleting resource", "authentication": "Authentication", + "authPages": "Auth Page", "protected": "Protected", "notProtected": "Not Protected", "resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.", diff --git a/server/db/pg/migrate.ts b/server/db/pg/migrate.ts index 70b2ef54..2d2abca3 100644 --- a/server/db/pg/migrate.ts +++ b/server/db/pg/migrate.ts @@ -10,7 +10,8 @@ const runMigrations = async () => { await migrate(db as any, { migrationsFolder: migrationsFolder }); - console.log("Migrations completed successfully."); + console.log("Migrations completed successfully. ✅"); + process.exit(0); } catch (error) { console.error("Error running migrations:", error); process.exit(1); diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 15c1942b..90cd1984 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -7,7 +7,8 @@ import { bigint, real, text, - index + index, + uniqueIndex } from "drizzle-orm/pg-core"; import { InferSelectModel } from "drizzle-orm"; import { randomUUID } from "crypto"; @@ -64,19 +65,23 @@ export const orgDomains = pgTable("orgDomains", { .references(() => domains.domainId, { onDelete: "cascade" }) }); -export const orgAuthPages = pgTable("orgAuthPages", { - orgId: varchar("orgId") - .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }), - orgAuthPageId: serial("orgAuthPageId").primaryKey(), - logoUrl: text("logoUrl"), - logoWidth: integer("logoWidth"), - logoHeight: integer("logoHeight"), - title: text("title"), - subtitle: text("subtitle"), - resourceTitle: text("resourceTitle"), - resourceSubtitle: text("resourceSubtitle") -}); +export const orgAuthPages = pgTable( + "orgAuthPages", + { + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + orgAuthPageId: serial("orgAuthPageId").primaryKey(), + logoUrl: text("logoUrl").notNull(), + logoWidth: integer("logoWidth").notNull(), + logoHeight: integer("logoHeight").notNull(), + title: text("title").notNull(), + subtitle: text("subtitle"), + resourceTitle: text("resourceTitle").notNull(), + resourceSubtitle: text("resourceSubtitle") + }, + (t) => [uniqueIndex("uniqueAuthPagePerOrgIdx").on(t.orgId)] +); export const sites = pgTable("sites", { siteId: serial("siteId").primaryKey(), diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index e259835d..5c293ffd 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1,6 +1,12 @@ import { randomUUID } from "crypto"; import { InferSelectModel } from "drizzle-orm"; -import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; +import { + sqliteTable, + text, + integer, + index, + uniqueIndex +} from "drizzle-orm/sqlite-core"; import { boolean } from "yargs"; export const domains = sqliteTable("domains", { @@ -66,19 +72,25 @@ export const orgDomains = sqliteTable("orgDomains", { .references(() => domains.domainId, { onDelete: "cascade" }) }); -export const orgAuthPages = sqliteTable("orgAuthPages", { - orgId: text("orgId") - .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }), - orgAuthPageId: integer("orgAuthPageId").primaryKey({ autoIncrement: true }), - logoUrl: text("logoUrl"), - logoWidth: integer("logoWidth"), - logoHeight: integer("logoHeight"), - title: text("title"), - subtitle: text("subtitle"), - resourceTitle: text("resourceTitle"), - resourceSubtitle: text("resourceSubtitle") -}); +export const orgAuthPages = sqliteTable( + "orgAuthPages", + { + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + orgAuthPageId: integer("orgAuthPageId").primaryKey({ + autoIncrement: true + }), + logoUrl: text("logoUrl").notNull(), + logoWidth: integer("logoWidth").notNull(), + logoHeight: integer("logoHeight").notNull(), + title: text("title").notNull(), + subtitle: text("subtitle"), + resourceTitle: text("resourceTitle").notNull(), + resourceSubtitle: text("resourceSubtitle") + }, + (t) => [uniqueIndex("uniqueAuthPagePerOrgIdx").on(t.orgId)] +); export const sites = sqliteTable("sites", { siteId: integer("siteId").primaryKey({ autoIncrement: true }), diff --git a/src/app/[orgId]/settings/general/auth-pages/page.tsx b/src/app/[orgId]/settings/general/auth-pages/page.tsx new file mode 100644 index 00000000..2ab90bdb --- /dev/null +++ b/src/app/[orgId]/settings/general/auth-pages/page.tsx @@ -0,0 +1,15 @@ +import AuthPageCustomizationForm from "@app/components/AuthPagesCustomizationForm"; +import { SettingsContainer } from "@app/components/Settings"; + +export interface AuthPageProps { + params: Promise<{ orgId: string }>; +} + +export default async function AuthPage(props: AuthPageProps) { + const orgId = (await props.params).orgId; + return ( + + + + ); +} diff --git a/src/app/[orgId]/settings/general/layout.tsx b/src/app/[orgId]/settings/general/layout.tsx index 82b2c999..fc501d82 100644 --- a/src/app/[orgId]/settings/general/layout.tsx +++ b/src/app/[orgId]/settings/general/layout.tsx @@ -1,7 +1,7 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { HorizontalTabs, type TabItem } from "@app/components/HorizontalTabs"; import { verifySession } from "@app/lib/auth/verifySession"; import OrgProvider from "@app/providers/OrgProvider"; import OrgUserProvider from "@app/providers/OrgUserProvider"; @@ -10,7 +10,7 @@ import { GetOrgUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { cache } from "react"; -import { getTranslations } from 'next-intl/server'; +import { getTranslations } from "next-intl/server"; type GeneralSettingsProps = { children: React.ReactNode; @@ -19,7 +19,7 @@ type GeneralSettingsProps = { export default async function GeneralSettingsPage({ children, - params, + params }: GeneralSettingsProps) { const { orgId } = await params; @@ -35,8 +35,8 @@ export default async function GeneralSettingsPage({ const getOrgUser = cache(async () => internal.get>( `/org/${orgId}/user/${user.userId}`, - await authCookieHeader(), - ), + await authCookieHeader() + ) ); const res = await getOrgUser(); orgUser = res.data.data; @@ -49,8 +49,8 @@ export default async function GeneralSettingsPage({ const getOrg = cache(async () => internal.get>( `/org/${orgId}`, - await authCookieHeader(), - ), + await authCookieHeader() + ) ); const res = await getOrg(); org = res.data.data; @@ -60,11 +60,16 @@ export default async function GeneralSettingsPage({ const t = await getTranslations(); - const navItems = [ + const navItems: TabItem[] = [ { - title: t('general'), + title: t("general"), href: `/{orgId}/settings/general`, + exact: true }, + { + title: t("authPages"), + href: `/{orgId}/settings/general/auth-pages` + } ]; return ( @@ -72,13 +77,11 @@ export default async function GeneralSettingsPage({ - - {children} - + {children} diff --git a/src/components/AuthPagesCustomizationForm.tsx b/src/components/AuthPagesCustomizationForm.tsx new file mode 100644 index 00000000..fc695eeb --- /dev/null +++ b/src/components/AuthPagesCustomizationForm.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import * as React from "react"; +import { useForm } from "react-hook-form"; +import z from "zod"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; + +export type AuthPageCustomizationProps = { + orgId: string; +}; + +const AuthPageFormSchema = z.object({ + logoUrl: z.string().url(), + logoWidth: z.number().min(1), + logoHeight: z.number().min(1), + title: z.string(), + subtitle: z.string().optional(), + resourceTitle: z.string(), + resourceSubtitle: z.string().optional() +}); + +export default function AuthPageCustomizationForm({ + orgId +}: AuthPageCustomizationProps) { + const [, formAction, isSubmitting] = React.useActionState(onSubmit, null); + + const form = useForm({ + resolver: zodResolver(AuthPageFormSchema), + defaultValues: { + title: `Log in to {{orgName}}`, + resourceTitle: `Authenticate to access {{resourceName}}` + } + }); + + async function onSubmit() { + const isValid = await form.trigger(); + + if (!isValid) return; + // ... + } + + return ( +
+ +
+ ); +} diff --git a/src/components/HorizontalTabs.tsx b/src/components/HorizontalTabs.tsx index 078cc660..7cbac226 100644 --- a/src/components/HorizontalTabs.tsx +++ b/src/components/HorizontalTabs.tsx @@ -8,16 +8,17 @@ import { Badge } from "@app/components/ui/badge"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useTranslations } from "next-intl"; -export type HorizontalTabs = Array<{ +export type TabItem = { title: string; href: string; icon?: React.ReactNode; showProfessional?: boolean; -}>; + exact?: boolean; +}; interface HorizontalTabsProps { children: React.ReactNode; - items: HorizontalTabs; + items: TabItem[]; disabled?: boolean; } @@ -49,8 +50,11 @@ export function HorizontalTabs({ {items.map((item) => { const hydratedHref = hydrateHref(item.href); const isActive = - pathname.startsWith(hydratedHref) && + (item.exact + ? pathname === hydratedHref + : pathname.startsWith(hydratedHref)) && !pathname.includes("create"); + const isProfessional = item.showProfessional && !isUnlocked(); const isDisabled = @@ -88,7 +92,7 @@ export function HorizontalTabs({ variant="outlinePrimary" className="ml-2" > - {t('licenseBadge')} + {t("licenseBadge")} )}