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")}
)}