Merge branch 'dev' into auth-providers-clients

This commit is contained in:
Owen
2025-04-29 11:39:12 -04:00
156 changed files with 12954 additions and 3559 deletions

View File

@@ -8,7 +8,8 @@ import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies";
import { redirect } from "next/navigation";
import { Layout } from "@app/components/Layout";
import { orgNavItems } from "../navigation";
import { orgLangingNavItems, orgNavItems, rootNavItems } from "../navigation";
import { ListUserOrgsResponse } from "@server/routers/org";
type OrgPageProps = {
params: Promise<{ orgId: string }>;
@@ -43,12 +44,23 @@ export default async function OrgPage(props: OrgPageProps) {
redirect(`/${orgId}/settings`);
}
let orgs: ListUserOrgsResponse["orgs"] = [];
try {
const getOrgs = cache(async () =>
internal.get<AxiosResponse<ListUserOrgsResponse>>(
`/user/${user.userId}/orgs`,
await authCookieHeader()
)
);
const res = await getOrgs();
if (res && res.data.data.orgs) {
orgs = res.data.data.orgs;
}
} catch (e) {}
return (
<UserProvider user={user}>
<Layout
orgId={orgId}
navItems={orgNavItems}
>
<Layout orgId={orgId} navItems={orgLangingNavItems} orgs={orgs}>
{overview && (
<div className="w-full max-w-4xl mx-auto md:mt-32 mt-4">
<OrganizationLandingCard

View File

@@ -1,369 +0,0 @@
"use client";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { InviteUserBody, InviteUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import CopyTextBox from "@app/components/CopyTextBox";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { ListRolesResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Checkbox } from "@app/components/ui/checkbox";
type InviteUserFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
};
const formSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
validForHours: z.string().min(1, { message: "Please select a duration" }),
roleId: z.string().min(1, { message: "Please select a role" })
});
export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
const { org } = useOrgContext();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [expiresInDays, setExpiresInDays] = useState(1);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
const validFor = [
{ hours: 24, name: "1 day" },
{ hours: 48, name: "2 days" },
{ hours: 72, name: "3 days" },
{ hours: 96, name: "4 days" },
{ hours: 120, name: "5 days" },
{ hours: 144, name: "6 days" },
{ hours: 168, name: "7 days" }
];
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
validForHours: "72",
roleId: ""
}
});
useEffect(() => {
if (open) {
setSendEmail(env.email.emailEnabled);
form.reset();
setInviteLink(null);
setExpiresInDays(1);
}
}, [open, env.email.emailEnabled, form]);
useEffect(() => {
if (!open) {
return;
}
async function fetchRoles() {
const res = await api
.get<
AxiosResponse<ListRolesResponse>
>(`/org/${org?.org.orgId}/roles`)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: "Failed to fetch roles",
description: formatAxiosError(
e,
"An error occurred while fetching the roles"
)
});
});
if (res?.status === 200) {
setRoles(res.data.data.roles);
}
}
fetchRoles();
}, [open]);
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true);
const res = await api
.post<AxiosResponse<InviteUserResponse>>(
`/org/${org?.org.orgId}/create-invite`,
{
email: values.email,
roleId: parseInt(values.roleId),
validHours: parseInt(values.validForHours),
sendEmail: sendEmail
} as InviteUserBody
)
.catch((e) => {
if (e.response?.status === 409) {
toast({
variant: "destructive",
title: "User Already Exists",
description:
"This user is already a member of the organization."
});
} else {
toast({
variant: "destructive",
title: "Failed to invite user",
description: formatAxiosError(
e,
"An error occurred while inviting the user"
)
});
}
});
if (res && res.status === 200) {
setInviteLink(res.data.data.inviteLink);
toast({
variant: "default",
title: "User invited",
description: "The user has been successfully invited."
});
setExpiresInDays(parseInt(values.validForHours) / 24);
}
setLoading(false);
}
return (
<>
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) {
setInviteLink(null);
setLoading(false);
setExpiresInDays(1);
form.reset();
}
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Invite User</CredenzaTitle>
<CredenzaDescription>
Give new users access to your organization
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-4">
{!inviteLink && (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="invite-user-form"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{env.email.emailEnabled && (
<div className="flex items-center space-x-2">
<Checkbox
id="send-email"
checked={sendEmail}
onCheckedChange={(e) =>
setSendEmail(
e as boolean
)
}
/>
<label
htmlFor="send-email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Send invite email to user
</label>
</div>
)}
<FormField
control={form.control}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(role) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{
role.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="validForHours"
render={({ field }) => (
<FormItem>
<FormLabel>
Valid For
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={field.value.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
</FormControl>
<SelectContent>
{validFor.map(
(option) => (
<SelectItem
key={
option.hours
}
value={option.hours.toString()}
>
{
option.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
{inviteLink && (
<div className="max-w-md space-y-4">
{sendEmail && (
<p>
An email has been sent to the user
with the access link below. They
must access the link to accept the
invitation.
</p>
)}
{!sendEmail && (
<p>
The user has been invited. They must
access the link below to accept the
invitation.
</p>
)}
<p>
The invite will expire in{" "}
<b>
{expiresInDays}{" "}
{expiresInDays === 1
? "day"
: "days"}
</b>
.
</p>
<CopyTextBox
text={inviteLink}
wrapText={false}
/>
</div>
)}
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="invite-user-form"
loading={loading}
disabled={inviteLink !== null || loading}
>
Create Invitation
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View File

@@ -24,7 +24,7 @@ export function UsersDataTable<TData, TValue>({
searchPlaceholder="Search users..."
searchColumn="email"
onAdd={inviteUser}
addButtonText="Invite User"
addButtonText="Create User"
/>
);
}

View File

@@ -11,7 +11,6 @@ import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
import { UsersDataTable } from "./UsersDataTable";
import { useState } from "react";
import InviteUserForm from "./InviteUserForm";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast";
@@ -41,16 +40,11 @@ type UsersTableProps = {
};
export default function UsersTable({ users: u }: UsersTableProps) {
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserRow | null>(null);
const [users, setUsers] = useState<UserRow[]>(u);
const router = useRouter();
const api = createApiClient(useEnvContext());
const { user, updateUser } = useUserContext();
const { org } = useOrgContext();
@@ -281,16 +275,11 @@ export default function UsersTable({ users: u }: UsersTableProps) {
title="Remove User from Organization"
/>
<InviteUserForm
open={isInviteModalOpen}
setOpen={setIsInviteModalOpen}
/>
<UsersDataTable
columns={columns}
data={users}
inviteUser={() => {
setIsInviteModalOpen(true);
router.push(`/${org?.org.orgId}/settings/access/users/create`);
}}
/>
</>

View File

@@ -0,0 +1,793 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { StrategySelect } from "@app/components/StrategySelect";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { InviteUserBody, InviteUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import CopyTextBox from "@app/components/CopyTextBox";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { ListRolesResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { Checkbox } from "@app/components/ui/checkbox";
import { ListIdpsResponse } from "@server/routers/idp";
type UserType = "internal" | "oidc";
interface UserTypeOption {
id: UserType;
title: string;
description: string;
}
interface IdpOption {
idpId: number;
name: string;
type: string;
}
const internalFormSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
validForHours: z.string().min(1, { message: "Please select a duration" }),
roleId: z.string().min(1, { message: "Please select a role" })
});
const externalFormSchema = z.object({
username: z.string().min(1, { message: "Username is required" }),
email: z
.string()
.email({ message: "Invalid email address" })
.optional()
.or(z.literal("")),
name: z.string().optional(),
roleId: z.string().min(1, { message: "Please select a role" }),
idpId: z.string().min(1, { message: "Please select an identity provider" })
});
const formatIdpType = (type: string) => {
switch (type.toLowerCase()) {
case "oidc":
return "Generic OAuth2/OIDC provider.";
default:
return type;
}
};
export default function Page() {
const { orgId } = useParams();
const router = useRouter();
const { env } = useEnvContext();
const api = createApiClient({ env });
const [userType, setUserType] = useState<UserType | null>("internal");
const [inviteLink, setInviteLink] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [expiresInDays, setExpiresInDays] = useState(1);
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
const [idps, setIdps] = useState<IdpOption[]>([]);
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
const [selectedIdp, setSelectedIdp] = useState<IdpOption | null>(null);
const [dataLoaded, setDataLoaded] = useState(false);
const validFor = [
{ hours: 24, name: "1 day" },
{ hours: 48, name: "2 days" },
{ hours: 72, name: "3 days" },
{ hours: 96, name: "4 days" },
{ hours: 120, name: "5 days" },
{ hours: 144, name: "6 days" },
{ hours: 168, name: "7 days" }
];
const internalForm = useForm<z.infer<typeof internalFormSchema>>({
resolver: zodResolver(internalFormSchema),
defaultValues: {
email: "",
validForHours: "72",
roleId: ""
}
});
const externalForm = useForm<z.infer<typeof externalFormSchema>>({
resolver: zodResolver(externalFormSchema),
defaultValues: {
username: "",
email: "",
name: "",
roleId: "",
idpId: ""
}
});
useEffect(() => {
if (userType === "internal") {
setSendEmail(env.email.emailEnabled);
internalForm.reset();
setInviteLink(null);
setExpiresInDays(1);
} else if (userType === "oidc") {
externalForm.reset();
}
}, [userType, env.email.emailEnabled, internalForm, externalForm]);
useEffect(() => {
if (!userType) {
return;
}
async function fetchRoles() {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(`/org/${orgId}/roles`)
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: "Failed to fetch roles",
description: formatAxiosError(
e,
"An error occurred while fetching the roles"
)
});
});
if (res?.status === 200) {
setRoles(res.data.data.roles);
if (userType === "internal") {
setDataLoaded(true);
}
}
}
async function fetchIdps() {
const res = await api
.get<AxiosResponse<ListIdpsResponse>>("/idp")
.catch((e) => {
console.error(e);
toast({
variant: "destructive",
title: "Failed to fetch identity providers",
description: formatAxiosError(
e,
"An error occurred while fetching identity providers"
)
});
});
if (res?.status === 200) {
setIdps(res.data.data.idps);
setDataLoaded(true);
}
}
setDataLoaded(false);
fetchRoles();
if (userType !== "internal") {
fetchIdps();
}
}, [userType]);
async function onSubmitInternal(
values: z.infer<typeof internalFormSchema>
) {
setLoading(true);
const res = await api
.post<AxiosResponse<InviteUserResponse>>(
`/org/${orgId}/create-invite`,
{
email: values.email,
roleId: parseInt(values.roleId),
validHours: parseInt(values.validForHours),
sendEmail: sendEmail
} as InviteUserBody
)
.catch((e) => {
if (e.response?.status === 409) {
toast({
variant: "destructive",
title: "User Already Exists",
description:
"This user is already a member of the organization."
});
} else {
toast({
variant: "destructive",
title: "Failed to invite user",
description: formatAxiosError(
e,
"An error occurred while inviting the user"
)
});
}
});
if (res && res.status === 200) {
setInviteLink(res.data.data.inviteLink);
toast({
variant: "default",
title: "User invited",
description: "The user has been successfully invited."
});
setExpiresInDays(parseInt(values.validForHours) / 24);
}
setLoading(false);
}
async function onSubmitExternal(
values: z.infer<typeof externalFormSchema>
) {
setLoading(true);
const res = await api
.put(`/org/${orgId}/user`, {
username: values.username,
email: values.email,
name: values.name,
type: "oidc",
idpId: parseInt(values.idpId),
roleId: parseInt(values.roleId)
})
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to create user",
description: formatAxiosError(
e,
"An error occurred while creating the user"
)
});
});
if (res && res.status === 201) {
toast({
variant: "default",
title: "User created",
description: "The user has been successfully created."
});
router.push(`/${orgId}/settings/access/users`);
}
setLoading(false);
}
const userTypes: ReadonlyArray<UserTypeOption> = [
{
id: "internal",
title: "Internal User",
description: "Invite a user to join your organization directly."
},
{
id: "oidc",
title: "External User",
description: "Create a user with an external identity provider."
}
];
return (
<>
<div className="flex justify-between">
<HeaderTitle
title="Create User"
description="Follow the steps below to create a new user"
/>
<Button
variant="outline"
onClick={() => {
router.push(`/${orgId}/settings/access/users`);
}}
>
See All Users
</Button>
</div>
<div>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
User Type
</SettingsSectionTitle>
<SettingsSectionDescription>
Determine how you want to create the user
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<StrategySelect
options={userTypes}
defaultValue={userType || undefined}
onChange={(value) => {
setUserType(value as UserType);
if (value === "internal") {
internalForm.reset();
} else if (value === "oidc") {
externalForm.reset();
setSelectedIdp(null);
}
}}
cols={2}
/>
</SettingsSectionBody>
</SettingsSection>
{userType === "internal" && dataLoaded && (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
User Information
</SettingsSectionTitle>
<SettingsSectionDescription>
Enter the details for the new user
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...internalForm}>
<form
onSubmit={internalForm.handleSubmit(
onSubmitInternal
)}
className="space-y-4"
id="create-user-form"
>
<FormField
control={
internalForm.control
}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
Email
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{env.email.emailEnabled && (
<div className="flex items-center space-x-2">
<Checkbox
id="send-email"
checked={sendEmail}
onCheckedChange={(
e
) =>
setSendEmail(
e as boolean
)
}
/>
<label
htmlFor="send-email"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Send invite email to
user
</label>
</div>
)}
<FormField
control={
internalForm.control
}
name="validForHours"
render={({ field }) => (
<FormItem>
<FormLabel>
Valid For
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
</FormControl>
<SelectContent>
{validFor.map(
(
option
) => (
<SelectItem
key={
option.hours
}
value={option.hours.toString()}
>
{
option.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
internalForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
Role
</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(
role
) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{
role.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{inviteLink && (
<div className="max-w-md space-y-4">
{sendEmail && (
<p>
An email has
been sent to the
user with the
access link
below. They must
access the link
to accept the
invitation.
</p>
)}
{!sendEmail && (
<p>
The user has
been invited.
They must access
the link below
to accept the
invitation.
</p>
)}
<p>
The invite will
expire in{" "}
<b>
{expiresInDays}{" "}
{expiresInDays ===
1
? "day"
: "days"}
</b>
.
</p>
<CopyTextBox
text={inviteLink}
wrapText={false}
/>
</div>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</>
)}
{userType !== "internal" && dataLoaded && (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Identity Provider
</SettingsSectionTitle>
<SettingsSectionDescription>
Select the identity provider for the
external user
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{idps.length === 0 ? (
<p className="text-muted-foreground">
No identity providers are
configured. Please configure an
identity provider before creating
external users.
</p>
) : (
<Form {...externalForm}>
<FormField
control={externalForm.control}
name="idpId"
render={({ field }) => (
<FormItem>
<StrategySelect
options={idps.map(
(idp) => ({
id: idp.idpId.toString(),
title: idp.name,
description:
formatIdpType(
idp.type
)
})
)}
defaultValue={
field.value
}
onChange={(
value
) => {
field.onChange(
value
);
const idp =
idps.find(
(idp) =>
idp.idpId.toString() ===
value
);
setSelectedIdp(
idp || null
);
}}
cols={3}
/>
<FormMessage />
</FormItem>
)}
/>
</Form>
)}
</SettingsSectionBody>
</SettingsSection>
{idps.length > 0 && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
User Information
</SettingsSectionTitle>
<SettingsSectionDescription>
Enter the details for the new user
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...externalForm}>
<form
onSubmit={externalForm.handleSubmit(
onSubmitExternal
)}
className="space-y-4"
id="create-user-form"
>
<FormField
control={
externalForm.control
}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>
Username
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<p className="text-sm text-muted-foreground mt-1">
This must
match the
unique
username
that exists
in the
selected
identity
provider.
</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
externalForm.control
}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
Email
(Optional)
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
externalForm.control
}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
Name
(Optional)
</FormLabel>
<FormControl>
<Input
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={
externalForm.control
}
name="roleId"
render={({ field }) => (
<FormItem>
<FormLabel>
Role
</FormLabel>
<Select
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map(
(
role
) => (
<SelectItem
key={
role.roleId
}
value={role.roleId.toString()}
>
{
role.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
</>
)}
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
variant="outline"
onClick={() => {
router.push(`/${orgId}/settings/access/users`);
}}
>
Cancel
</Button>
{userType && dataLoaded && (
<Button
type="submit"
form="create-user-form"
loading={loading}
disabled={
loading ||
(userType === "internal" && inviteLink !== null)
}
>
Create User
</Button>
)}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,33 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { DataTable } from "@app/components/ui/data-table";
import { ColumnDef } from "@tanstack/react-table";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
addApiKey?: () => void;
}
export function OrgApiKeysDataTable<TData, TValue>({
addApiKey,
columns,
data
}: DataTableProps<TData, TValue>) {
return (
<DataTable
columns={columns}
data={data}
title="API Keys"
searchPlaceholder="Search API keys..."
searchColumn="name"
onAdd={addApiKey}
addButtonText="Generate API Key"
/>
);
}

View File

@@ -0,0 +1,204 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { OrgApiKeysDataTable } from "./OrgApiKeysDataTable";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import moment from "moment";
export type OrgApiKeyRow = {
id: string;
key: string;
name: string;
createdAt: string;
};
type OrgApiKeyTableProps = {
apiKeys: OrgApiKeyRow[];
orgId: string;
};
export default function OrgApiKeysTable({
apiKeys,
orgId
}: OrgApiKeyTableProps) {
const router = useRouter();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selected, setSelected] = useState<OrgApiKeyRow | null>(null);
const [rows, setRows] = useState<OrgApiKeyRow[]>(apiKeys);
const api = createApiClient(useEnvContext());
const deleteSite = (apiKeyId: string) => {
api.delete(`/org/${orgId}/api-key/${apiKeyId}`)
.catch((e) => {
console.error("Error deleting API key", e);
toast({
variant: "destructive",
title: "Error deleting API key",
description: formatAxiosError(e, "Error deleting API key")
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
const newRows = rows.filter((row) => row.id !== apiKeyId);
setRows(newRows);
});
};
const columns: ColumnDef<OrgApiKeyRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const apiKeyROw = row.original;
const router = useRouter();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelected(apiKeyROw);
}}
>
<span>View settings</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelected(apiKeyROw);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "key",
header: "Key",
cell: ({ row }) => {
const r = row.original;
return <span className="font-mono">{r.key}</span>;
}
},
{
accessorKey: "createdAt",
header: "Created At",
cell: ({ row }) => {
const r = row.original;
return <span>{moment(r.createdAt).format("lll")} </span>;
}
},
{
id: "actions",
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex items-center justify-end">
<Link href={`/${orgId}/settings/api-keys/${r.id}`}>
<Button variant={"outlinePrimary"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
return (
<>
{selected && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelected(null);
}}
dialog={
<div className="space-y-4">
<p>
Are you sure you want to remove the API key{" "}
<b>{selected?.name || selected?.id}</b> from the
organization?
</p>
<p>
<b>
Once removed, the API key will no longer be
able to be used.
</b>
</p>
<p>
To confirm, please type the name of the API key
below.
</p>
</div>
}
buttonText="Confirm Delete API Key"
onConfirm={async () => deleteSite(selected!.id)}
string={selected.name}
title="Delete API Key"
/>
)}
<OrgApiKeysDataTable
columns={columns}
data={rows}
addApiKey={() => {
router.push(`/${orgId}/settings/api-keys/create`);
}}
/>
</>
);
}

View File

@@ -0,0 +1,62 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import { SidebarSettings } from "@app/components/SidebarSettings";
import Link from "next/link";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import { GetApiKeyResponse } from "@server/routers/apiKeys";
import ApiKeyProvider from "@app/providers/ApiKeyProvider";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
interface SettingsLayoutProps {
children: React.ReactNode;
params: Promise<{ apiKeyId: string; orgId: string }>;
}
export default async function SettingsLayout(props: SettingsLayoutProps) {
const params = await props.params;
const { children } = props;
let apiKey = null;
try {
const res = await internal.get<AxiosResponse<GetApiKeyResponse>>(
`/org/${params.orgId}/api-key/${params.apiKeyId}`,
await authCookieHeader()
);
apiKey = res.data.data;
} catch (e) {
console.log(e);
redirect(`/${params.orgId}/settings/api-keys`);
}
const navItems = [
{
title: "Permissions",
href: "/{orgId}/settings/api-keys/{apiKeyId}/permissions"
}
];
return (
<>
<SettingsSectionTitle title={`${apiKey?.name} Settings`} />
<ApiKeyProvider apiKey={apiKey}>
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</ApiKeyProvider>
</>
);
}

View File

@@ -0,0 +1,13 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { redirect } from "next/navigation";
export default async function ApiKeysPage(props: {
params: Promise<{ orgId: string; apiKeyId: string }>;
}) {
const params = await props.params;
redirect(`/${params.orgId}/settings/api-keys/${params.apiKeyId}/permissions`);
}

View File

@@ -0,0 +1,138 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { ListApiKeyActionsResponse } from "@server/routers/apiKeys";
import { AxiosResponse } from "axios";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
export default function Page() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const { orgId, apiKeyId } = useParams();
const [loadingPage, setLoadingPage] = useState<boolean>(true);
const [selectedPermissions, setSelectedPermissions] = useState<
Record<string, boolean>
>({});
const [loadingSavePermissions, setLoadingSavePermissions] =
useState<boolean>(false);
useEffect(() => {
async function load() {
setLoadingPage(true);
const res = await api
.get<
AxiosResponse<ListApiKeyActionsResponse>
>(`/org/${orgId}/api-key/${apiKeyId}/actions`)
.catch((e) => {
toast({
variant: "destructive",
title: "Error loading API key actions",
description: formatAxiosError(
e,
"Error loading API key actions"
)
});
});
if (res && res.status === 200) {
const data = res.data.data;
for (const action of data.actions) {
setSelectedPermissions((prev) => ({
...prev,
[action.actionId]: true
}));
}
}
setLoadingPage(false);
}
load();
}, []);
async function savePermissions() {
setLoadingSavePermissions(true);
const actionsRes = await api
.post(`/org/${orgId}/api-key/${apiKeyId}/actions`, {
actionIds: Object.keys(selectedPermissions).filter(
(key) => selectedPermissions[key]
)
})
.catch((e) => {
console.error("Error setting permissions", e);
toast({
variant: "destructive",
title: "Error setting permissions",
description: formatAxiosError(e)
});
});
if (actionsRes && actionsRes.status === 200) {
toast({
title: "Permissions updated",
description: "The permissions have been updated."
});
}
setLoadingSavePermissions(false);
}
return (
<>
{!loadingPage && (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Permissions
</SettingsSectionTitle>
<SettingsSectionDescription>
Determine what this API key can do
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<PermissionsSelectBox
selectedPermissions={selectedPermissions}
onChange={setSelectedPermissions}
/>
<SettingsSectionFooter>
<Button
onClick={async () => {
await savePermissions();
}}
loading={loadingSavePermissions}
disabled={loadingSavePermissions}
>
Save Permissions
</Button>
</SettingsSectionFooter>
</SettingsSectionBody>
</SettingsSection>
</SettingsContainer>
)}
</>
);
}

View File

@@ -0,0 +1,412 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { z } from "zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input";
import { InfoIcon } from "lucide-react";
import { Button } from "@app/components/ui/button";
import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { AxiosResponse } from "axios";
import { useParams, useRouter } from "next/navigation";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import Link from "next/link";
import {
CreateOrgApiKeyBody,
CreateOrgApiKeyResponse
} from "@server/routers/apiKeys";
import { ApiKey } from "@server/db/schemas";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard";
import moment from "moment";
import CopyCodeBox from "@server/emails/templates/components/CopyCodeBox";
import CopyTextBox from "@app/components/CopyTextBox";
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
const createFormSchema = z.object({
name: z
.string()
.min(2, {
message: "Name must be at least 2 characters."
})
.max(255, {
message: "Name must not be longer than 255 characters."
})
});
type CreateFormValues = z.infer<typeof createFormSchema>;
const copiedFormSchema = z
.object({
copied: z.boolean()
})
.refine(
(data) => {
return data.copied;
},
{
message: "You must confirm that you have copied the API key.",
path: ["copied"]
}
);
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
export default function Page() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const { orgId } = useParams();
const router = useRouter();
const [loadingPage, setLoadingPage] = useState(true);
const [createLoading, setCreateLoading] = useState(false);
const [apiKey, setApiKey] = useState<CreateOrgApiKeyResponse | null>(null);
const [selectedPermissions, setSelectedPermissions] = useState<
Record<string, boolean>
>({});
const form = useForm<CreateFormValues>({
resolver: zodResolver(createFormSchema),
defaultValues: {
name: ""
}
});
const copiedForm = useForm<CopiedFormValues>({
resolver: zodResolver(copiedFormSchema),
defaultValues: {
copied: false
}
});
async function onSubmit(data: CreateFormValues) {
setCreateLoading(true);
let payload: CreateOrgApiKeyBody = {
name: data.name
};
const res = await api
.put<
AxiosResponse<CreateOrgApiKeyResponse>
>(`/org/${orgId}/api-key/`, payload)
.catch((e) => {
toast({
variant: "destructive",
title: "Error creating API key",
description: formatAxiosError(e)
});
});
if (res && res.status === 201) {
const data = res.data.data;
console.log({
actionIds: Object.keys(selectedPermissions).filter(
(key) => selectedPermissions[key]
)
});
const actionsRes = await api
.post(`/org/${orgId}/api-key/${data.apiKeyId}/actions`, {
actionIds: Object.keys(selectedPermissions).filter(
(key) => selectedPermissions[key]
)
})
.catch((e) => {
console.error("Error setting permissions", e);
toast({
variant: "destructive",
title: "Error setting permissions",
description: formatAxiosError(e)
});
});
if (actionsRes) {
setApiKey(data);
}
}
setCreateLoading(false);
}
async function onCopiedSubmit(data: CopiedFormValues) {
if (!data.copied) {
return;
}
router.push(`/${orgId}/settings/api-keys`);
}
const formatLabel = (str: string) => {
return str
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/^./, (char) => char.toUpperCase());
};
useEffect(() => {
const load = async () => {
setLoadingPage(false);
};
load();
}, []);
return (
<>
<div className="flex justify-between">
<HeaderTitle
title="Generate API Key"
description="Generate a new API key for your organization"
/>
<Button
variant="outline"
onClick={() => {
router.push(`/${orgId}/settings/api-keys`);
}}
>
See All API Keys
</Button>
</div>
{!loadingPage && (
<div>
<SettingsContainer>
{!apiKey && (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
API Key Information
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
className="space-y-4"
id="create-site-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
Name
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Permissions
</SettingsSectionTitle>
<SettingsSectionDescription>
Determine what this API key can do
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<PermissionsSelectBox
selectedPermissions={
selectedPermissions
}
onChange={setSelectedPermissions}
/>
</SettingsSectionBody>
</SettingsSection>
</>
)}
{apiKey && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Your API Key
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle>
Name
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={apiKey.name}
/>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
Created
</InfoSectionTitle>
<InfoSectionContent>
{moment(
apiKey.createdAt
).format("lll")}
</InfoSectionContent>
</InfoSection>
</InfoSections>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
Save Your API Key
</AlertTitle>
<AlertDescription>
You will only be able to see this
once. Make sure to copy it to a
secure place.
</AlertDescription>
</Alert>
<h4 className="font-semibold">
Your API key is:
</h4>
<CopyTextBox
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
/>
<Form {...copiedForm}>
<form
className="space-y-4"
id="copied-form"
>
<FormField
control={copiedForm.control}
name="copied"
render={({ field }) => (
<FormItem>
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
defaultChecked={
copiedForm.getValues(
"copied"
) as boolean
}
onCheckedChange={(
e
) => {
copiedForm.setValue(
"copied",
e as boolean
);
}}
/>
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I have copied
the API key
</label>
</div>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
{!apiKey && (
<Button
type="button"
variant="outline"
disabled={createLoading || apiKey !== null}
onClick={() => {
router.push(`/${orgId}/settings/api-keys`);
}}
>
Cancel
</Button>
)}
{!apiKey && (
<Button
type="button"
loading={createLoading}
disabled={createLoading || apiKey !== null}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
Generate
</Button>
)}
{apiKey && (
<Button
type="button"
onClick={() => {
copiedForm.handleSubmit(onCopiedSubmit)();
}}
>
Done
</Button>
)}
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,49 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import OrgApiKeysTable, { OrgApiKeyRow } from "./OrgApiKeysTable";
import { ListOrgApiKeysResponse } from "@server/routers/apiKeys";
type ApiKeyPageProps = {
params: Promise<{ orgId: string }>;
};
export const dynamic = "force-dynamic";
export default async function ApiKeysPage(props: ApiKeyPageProps) {
const params = await props.params;
let apiKeys: ListOrgApiKeysResponse["apiKeys"] = [];
try {
const res = await internal.get<AxiosResponse<ListOrgApiKeysResponse>>(
`/org/${params.orgId}/api-keys`,
await authCookieHeader()
);
apiKeys = res.data.data.apiKeys;
} catch (e) {}
const rows: OrgApiKeyRow[] = apiKeys.map((key) => {
return {
name: key.name,
id: key.apiKeyId,
key: `${key.apiKeyId}••••••••••••••••••••${key.lastChars}`,
createdAt: key.createdAt
};
});
return (
<>
<SettingsSectionTitle
title="Manage API Keys"
description="API keys are used to authenticate with the integration API"
/>
<OrgApiKeysTable apiKeys={rows} orgId={params.orgId} />
</>
);
}

View File

@@ -242,7 +242,7 @@ export default function GeneralPage() {
loading={loadingSave}
disabled={loadingSave}
>
Save Settings
Save General Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>

View File

@@ -21,10 +21,8 @@ import {
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import CreateResourceForm from "./CreateResourceForm";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { set } from "zod";
import { formatAxiosError } from "@app/lib/api";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
@@ -58,7 +56,6 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
const api = createApiClient(useEnvContext());
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedResource, setSelectedResource] =
useState<ResourceRow | null>();
@@ -242,7 +239,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
<span>Not Protected</span>
</span>
) : (
<span>--</span>
<span>-</span>
)}
</div>
);
@@ -282,11 +279,6 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
return (
<>
<CreateResourceForm
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
/>
{selectedResource && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
@@ -328,7 +320,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
columns={columns}
data={resources}
createResource={() => {
setIsCreateModalOpen(true);
router.push(`/${orgId}/settings/resources/create`);
}}
/>
</>

View File

@@ -140,12 +140,6 @@ export default function SetResourcePasswordForm({
/>
</FormControl>
<FormMessage />
<FormDescription>
Users will be able to access
this resource by entering this
password. It must be at least 4
characters long.
</FormDescription>
</FormItem>
)}
/>

View File

@@ -147,33 +147,33 @@ export default function SetResourcePincodeForm({
<InputOTPGroup className="flex">
<InputOTPSlot
index={0}
obscured
/>
<InputOTPSlot
index={1}
obscured
/>
<InputOTPSlot
index={2}
obscured
/>
<InputOTPSlot
index={3}
obscured
/>
<InputOTPSlot
index={4}
obscured
/>
<InputOTPSlot
index={5}
obscured
/>
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormMessage />
<FormDescription>
Users will be able to access
this resource by entering this
PIN code. It must be at least 6
digits long.
</FormDescription>
</FormItem>
)}
/>

View File

@@ -46,6 +46,8 @@ import { InfoPopup } from "@app/components/ui/info-popup";
import { Tag, TagInput } from "@app/components/tags/tag-input";
import { useRouter } from "next/navigation";
import { UserType } from "@server/types/UserTypes";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon } from "lucide-react";
const UsersRolesFormSchema = z.object({
roles: z.array(
@@ -612,117 +614,127 @@ export default function ResourceAuthenticationPage() {
</SettingsSectionBody>
</SettingsSection>
{env.email.emailEnabled && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
One-time Passwords
</SettingsSectionTitle>
<SettingsSectionDescription>
Require email-based authentication for resource
access
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SwitchInput
id="whitelist-toggle"
label="Email Whitelist"
defaultChecked={resource.emailWhitelistEnabled}
onCheckedChange={setWhitelistEnabled}
/>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
One-time Passwords
</SettingsSectionTitle>
<SettingsSectionDescription>
Require email-based authentication for resource
access
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{!env.email.emailEnabled && (
<Alert variant="neutral" className="mb-4">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
SMTP Required
</AlertTitle>
<AlertDescription>
SMTP must be enabled on the server to use one-time password authentication.
</AlertDescription>
</Alert>
)}
<SwitchInput
id="whitelist-toggle"
label="Email Whitelist"
defaultChecked={resource.emailWhitelistEnabled}
onCheckedChange={setWhitelistEnabled}
disabled={!env.email.emailEnabled}
/>
{whitelistEnabled && (
<Form {...whitelistForm}>
<form id="whitelist-form">
<FormField
control={whitelistForm.control}
name="emails"
render={({ field }) => (
<FormItem>
<FormLabel>
<InfoPopup
text="Whitelisted Emails"
info="Only users with these email addresses will be able to access this resource. They will be prompted to enter a one-time password sent to their email. Wildcards (*@example.com) can be used to allow any email address from a domain."
/>
</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
activeEmailTagIndex
}
size={"sm"}
validateTag={(
tag
) => {
return z
.string()
.email()
.or(
z
.string()
.regex(
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
{
message:
"Invalid email address. Wildcard (*) must be the entire local part."
}
)
)
.safeParse(
tag
).success;
}}
setActiveTagIndex={
setActiveEmailTagIndex
}
placeholder="Enter an email"
tags={
whitelistForm.getValues()
.emails
}
setTags={(
newRoles
) => {
whitelistForm.setValue(
"emails",
newRoles as [
Tag,
...Tag[]
]
);
}}
allowDuplicates={
false
}
sortTags={true}
/>
</FormControl>
<FormDescription>
Press enter to add an
email after typing it in
the input field.
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
)}
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
onClick={saveWhitelist}
form="whitelist-form"
loading={loadingSaveWhitelist}
disabled={loadingSaveWhitelist}
>
Save Whitelist
</Button>
</SettingsSectionFooter>
</SettingsSection>
)}
{whitelistEnabled && env.email.emailEnabled && (
<Form {...whitelistForm}>
<form id="whitelist-form">
<FormField
control={whitelistForm.control}
name="emails"
render={({ field }) => (
<FormItem>
<FormLabel>
<InfoPopup
text="Whitelisted Emails"
info="Only users with these email addresses will be able to access this resource. They will be prompted to enter a one-time password sent to their email. Wildcards (*@example.com) can be used to allow any email address from a domain."
/>
</FormLabel>
<FormControl>
{/* @ts-ignore */}
<TagInput
{...field}
activeTagIndex={
activeEmailTagIndex
}
size={"sm"}
validateTag={(
tag
) => {
return z
.string()
.email()
.or(
z
.string()
.regex(
/^\*@[\w.-]+\.[a-zA-Z]{2,}$/,
{
message:
"Invalid email address. Wildcard (*) must be the entire local part."
}
)
)
.safeParse(
tag
).success;
}}
setActiveTagIndex={
setActiveEmailTagIndex
}
placeholder="Enter an email"
tags={
whitelistForm.getValues()
.emails
}
setTags={(
newRoles
) => {
whitelistForm.setValue(
"emails",
newRoles as [
Tag,
...Tag[]
]
);
}}
allowDuplicates={
false
}
sortTags={true}
/>
</FormControl>
<FormDescription>
Press enter to add an
email after typing it in
the input field.
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
)}
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
onClick={saveWhitelist}
form="whitelist-form"
loading={loadingSaveWhitelist}
disabled={loadingSaveWhitelist}
>
Save Whitelist
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
</>
);

View File

@@ -48,7 +48,7 @@ import { useOrgContext } from "@app/hooks/useOrgContext";
import CustomDomainInput from "../CustomDomainInput";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { subdomainSchema } from "@server/lib/schemas";
import { subdomainSchema, tlsNameSchema } from "@server/lib/schemas";
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Label } from "@app/components/ui/label";
@@ -596,7 +596,7 @@ export default function GeneralForm() {
disabled={saveLoading}
form="general-settings-form"
>
Save Settings
Save General Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>

View File

@@ -86,8 +86,8 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
href: `/{orgId}/settings/resources/{resourceId}/general`
},
{
title: "Connectivity",
href: `/{orgId}/settings/resources/{resourceId}/connectivity`
title: "Proxy",
href: `/{orgId}/settings/resources/{resourceId}/proxy`
}
];

View File

@@ -5,6 +5,6 @@ export default async function ResourcePage(props: {
}) {
const params = await props.params;
redirect(
`/${params.orgId}/settings/resources/${params.resourceId}/connectivity`
`/${params.orgId}/settings/resources/${params.resourceId}/proxy`
);
}

View File

@@ -60,17 +60,28 @@ import {
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionFooter
SettingsSectionFooter,
SettingsSectionForm
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { useRouter } from "next/navigation";
import { isTargetValid } from "@server/lib/validators";
import { tlsNameSchema } from "@server/lib/schemas";
import { ChevronsUpDown } from "lucide-react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from "@app/components/ui/collapsible";
const addTargetSchema = z.object({
ip: z.string().refine(isTargetValid),
method: z.string().nullable(),
port: z.coerce.number().int().positive()
// protocol: z.string(),
});
const targetsSettingsSchema = z.object({
stickySession: z.boolean()
});
type LocalTarget = Omit<
@@ -81,6 +92,47 @@ type LocalTarget = Omit<
"protocol"
>;
const proxySettingsSchema = z.object({
setHostHeader: z
.string()
.optional()
.refine(
(data) => {
if (data) {
return tlsNameSchema.safeParse(data).success;
}
return true;
},
{
message:
"Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header."
}
)
});
const tlsSettingsSchema = z.object({
ssl: z.boolean(),
tlsServerName: z
.string()
.optional()
.refine(
(data) => {
if (data) {
return tlsNameSchema.safeParse(data).success;
}
return true;
},
{
message:
"Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name."
}
)
});
type ProxySettingsValues = z.infer<typeof proxySettingsSchema>;
type TlsSettingsValues = z.infer<typeof tlsSettingsSchema>;
type TargetsSettingsValues = z.infer<typeof targetsSettingsSchema>;
export default function ReverseProxyTargets(props: {
params: Promise<{ resourceId: number }>;
}) {
@@ -93,11 +145,13 @@ export default function ReverseProxyTargets(props: {
const [targets, setTargets] = useState<LocalTarget[]>([]);
const [site, setSite] = useState<GetSiteResponse>();
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
const [sslEnabled, setSslEnabled] = useState(resource.ssl);
const [loading, setLoading] = useState(false);
const [httpsTlsLoading, setHttpsTlsLoading] = useState(false);
const [targetsLoading, setTargetsLoading] = useState(false);
const [proxySettingsLoading, setProxySettingsLoading] = useState(false);
const [pageLoading, setPageLoading] = useState(true);
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const router = useRouter();
const addTargetForm = useForm({
@@ -109,6 +163,28 @@ export default function ReverseProxyTargets(props: {
} as z.infer<typeof addTargetSchema>
});
const tlsSettingsForm = useForm<TlsSettingsValues>({
resolver: zodResolver(tlsSettingsSchema),
defaultValues: {
ssl: resource.ssl,
tlsServerName: resource.tlsServerName || ""
}
});
const proxySettingsForm = useForm<ProxySettingsValues>({
resolver: zodResolver(proxySettingsSchema),
defaultValues: {
setHostHeader: resource.setHostHeader || ""
}
});
const targetsSettingsForm = useForm<TargetsSettingsValues>({
resolver: zodResolver(targetsSettingsSchema),
defaultValues: {
stickySession: resource.stickySession
}
});
useEffect(() => {
const fetchTargets = async () => {
try {
@@ -229,13 +305,12 @@ export default function ReverseProxyTargets(props: {
async function saveTargets() {
try {
setLoading(true);
setTargetsLoading(true);
for (let target of targets) {
const data = {
ip: target.ip,
port: target.port,
// protocol: target.protocol,
method: target.method,
enabled: target.enabled
};
@@ -248,27 +323,22 @@ export default function ReverseProxyTargets(props: {
} else if (target.updated) {
await api.post(`/target/${target.targetId}`, data);
}
setTargets([
...targets.map((t) => {
let res = {
...t,
new: false,
updated: false
};
return res;
})
]);
}
for (const targetId of targetsToRemove) {
await api.delete(`/target/${targetId}`);
setTargets(targets.filter((t) => t.targetId !== targetId));
}
// Save sticky session setting
const stickySessionData = targetsSettingsForm.getValues();
await api.post(`/resource/${params.resourceId}`, {
stickySession: stickySessionData.stickySession
});
updateResource({ stickySession: stickySessionData.stickySession });
toast({
title: "Targets updated",
description: "Targets updated successfully"
description: "Targets and settings updated successfully"
});
setTargetsToRemove([]);
@@ -277,43 +347,75 @@ export default function ReverseProxyTargets(props: {
console.error(err);
toast({
variant: "destructive",
title: "Operation failed",
title: "Failed to update targets",
description: formatAxiosError(
err,
"An error occurred during the save operation"
"An error occurred while updating targets"
)
});
} finally {
setTargetsLoading(false);
}
setLoading(false);
}
async function saveSsl(val: boolean) {
const res = await api
.post(`/resource/${params.resourceId}`, {
ssl: val
})
.catch((err) => {
console.error(err);
toast({
variant: "destructive",
title: "Failed to update SSL configuration",
description: formatAxiosError(
err,
"An error occurred while updating the SSL configuration"
)
});
async function saveTlsSettings(data: TlsSettingsValues) {
try {
setHttpsTlsLoading(true);
await api.post(`/resource/${params.resourceId}`, {
ssl: data.ssl,
tlsServerName: data.tlsServerName || undefined
});
updateResource({
...resource,
ssl: data.ssl,
tlsServerName: data.tlsServerName || undefined
});
if (res && res.status === 200) {
setSslEnabled(val);
updateResource({ ssl: val });
toast({
title: "SSL Configuration",
description: "SSL configuration updated successfully"
title: "TLS settings updated",
description: "Your TLS settings have been updated successfully"
});
router.refresh();
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: "Failed to update TLS settings",
description: formatAxiosError(
err,
"An error occurred while updating TLS settings"
)
});
} finally {
setHttpsTlsLoading(false);
}
}
async function saveProxySettings(data: ProxySettingsValues) {
try {
setProxySettingsLoading(true);
await api.post(`/resource/${params.resourceId}`, {
setHostHeader: data.setHostHeader || undefined
});
updateResource({
...resource,
setHostHeader: data.setHostHeader || undefined
});
toast({
title: "Proxy settings updated",
description:
"Your proxy settings have been updated successfully"
});
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: "Failed to update proxy settings",
description: formatAxiosError(
err,
"An error occurred while updating proxy settings"
)
});
} finally {
setProxySettingsLoading(false);
}
}
@@ -456,35 +558,159 @@ export default function ReverseProxyTargets(props: {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
SSL Configuration
HTTPS & TLS Settings
</SettingsSectionTitle>
<SettingsSectionDescription>
Set up SSL to secure your connections with certificates
Configure TLS settings for your resource
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SwitchInput
id="ssl-toggle"
label="Enable SSL (https)"
defaultChecked={resource.ssl}
onCheckedChange={async (val) => {
await saveSsl(val);
}}
/>
<SettingsSectionForm>
<Form {...tlsSettingsForm}>
<form
onSubmit={tlsSettingsForm.handleSubmit(
saveTlsSettings
)}
className="space-y-4"
id="tls-settings-form"
>
<FormField
control={tlsSettingsForm.control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="ssl-toggle"
label="Enable SSL (https)"
defaultChecked={
field.value
}
onCheckedChange={(
val
) => {
field.onChange(val);
}}
/>
</FormControl>
</FormItem>
)}
/>
<Collapsible
open={isAdvancedOpen}
onOpenChange={setIsAdvancedOpen}
className="space-y-2"
>
<div className="flex items-center justify-between space-x-4">
<CollapsibleTrigger asChild>
<Button
variant="text"
size="sm"
className="p-0 flex items-center justify-start gap-2 w-full"
>
<h4 className="text-sm font-semibold">
Advanced TLS Settings
</h4>
<div>
<ChevronsUpDown className="h-4 w-4" />
<span className="sr-only">
Toggle
</span>
</div>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="space-y-2">
<FormField
control={
tlsSettingsForm.control
}
name="tlsServerName"
render={({ field }) => (
<FormItem>
<FormLabel>
TLS Server Name
(SNI)
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The TLS Server Name
to use for SNI.
Leave empty to use
the default.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={httpsTlsLoading}
form="tls-settings-form"
>
Save HTTPS & TLS Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
)}
{/* Targets Section */}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Target Configuration
Targets Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Set up targets to route traffic to your services
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...targetsSettingsForm}>
<form
onSubmit={targetsSettingsForm.handleSubmit(
saveTargets
)}
className="space-y-4"
id="targets-settings-form"
>
{targets.length >= 2 && (
<FormField
control={targetsSettingsForm.control}
name="stickySession"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="sticky-toggle"
label="Enable Sticky Sessions"
description="Keep connections on the same backend target for their entire session."
defaultChecked={
field.value
}
onCheckedChange={(
val
) => {
field.onChange(val);
}}
/>
</FormControl>
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>
<Form {...addTargetForm}>
<form
onSubmit={addTargetForm.handleSubmit(addTarget)}
@@ -629,13 +855,70 @@ export default function ReverseProxyTargets(props: {
<SettingsSectionFooter>
<Button
onClick={saveTargets}
loading={loading}
disabled={loading}
loading={targetsLoading}
disabled={targetsLoading}
form="targets-settings-form"
>
Save Targets
</Button>
</SettingsSectionFooter>
</SettingsSection>
{resource.http && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Additional Proxy Settings
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure how your resource handles proxy settings
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...proxySettingsForm}>
<form
onSubmit={proxySettingsForm.handleSubmit(
saveProxySettings
)}
className="space-y-4"
id="proxy-settings-form"
>
<FormField
control={proxySettingsForm.control}
name="setHostHeader"
render={({ field }) => (
<FormItem>
<FormLabel>
Custom Host Header
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The Host header to set when
proxying requests. Leave
empty to use the default.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={proxySettingsLoading}
form="proxy-settings-form"
>
Save Proxy Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
)}
</SettingsContainer>
);
}

View File

@@ -57,9 +57,7 @@ import {
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { Checkbox } from "@app/components/ui/checkbox";
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
import {
constructShareLink
} from "@app/lib/shareLinks";
import { constructShareLink } from "@app/lib/shareLinks";
import { ShareLinkRow } from "./ShareLinksTable";
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
import {
@@ -528,11 +526,9 @@ export default function CreateShareLinkForm({
accessTokenId
}
token={accessToken}
resourceUrl={
form.getValues(
"resourceUrl"
)
}
resourceUrl={form.getValues(
"resourceUrl"
)}
/>
</div>
</div>

View File

@@ -228,11 +228,11 @@ export default function CreateSiteForm({
mbIn:
data.type == "wireguard" || data.type == "newt"
? "0 MB"
: "--",
: "-",
mbOut:
data.type == "wireguard" || data.type == "newt"
? "0 MB"
: "--",
: "-",
orgId: orgId as string,
type: data.type as any,
online: false

View File

@@ -164,7 +164,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
);
}
} else {
return <span>--</span>;
return <span>-</span>;
}
}
},

View File

@@ -134,7 +134,7 @@ export default function GeneralPage() {
loading={loading}
disabled={loading}
>
Save Settings
Save General Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>

View File

@@ -29,14 +29,29 @@ import { InfoIcon, Terminal } from "lucide-react";
import { Button } from "@app/components/ui/button";
import CopyTextBox from "@app/components/CopyTextBox";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { InfoSection, InfoSectionContent, InfoSections, InfoSectionTitle } from "@app/components/InfoSection";
import { FaApple, FaCubes, FaDocker, FaFreebsd, FaWindows } from "react-icons/fa";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import {
FaApple,
FaCubes,
FaDocker,
FaFreebsd,
FaWindows
} from "react-icons/fa";
import { Checkbox } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { generateKeypair } from "../[niceId]/wireguardConfig";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { CreateSiteBody, CreateSiteResponse, PickSiteDefaultsResponse } from "@server/routers/site";
import {
CreateSiteBody,
CreateSiteResponse,
PickSiteDefaultsResponse
} from "@server/routers/site";
import { toast } from "@app/hooks/useToast";
import { AxiosResponse } from "axios";
import { useParams, useRouter } from "next/navigation";
@@ -48,6 +63,7 @@ import {
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import Link from "next/link";
import { QRCodeCanvas } from "qrcode.react";
const createSiteFormSchema = z
.object({
@@ -102,7 +118,7 @@ const platforms = [
"freebsd"
] as const;
type Platform = typeof platforms[number];
type Platform = (typeof platforms)[number];
export default function Page() {
const { env } = useEnvContext();
@@ -769,7 +785,9 @@ WantedBy=default.target`
<div>
<p className="font-bold mb-3">
{["docker", "podman"].includes(platform)
{["docker", "podman"].includes(
platform
)
? "Method"
: "Architecture"}
</p>
@@ -827,8 +845,20 @@ WantedBy=default.target`
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<CopyTextBox text={wgConfig} />
<div className="flex items-center gap-4">
<CopyTextBox text={wgConfig} />
<div
className={`relative w-fit border rounded-md`}
>
<div className="bg-white p-6 rounded-md">
<QRCodeCanvas
value={wgConfig}
size={168}
className="mx-auto"
/>
</div>
</div>
</div>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">

View File

@@ -25,7 +25,7 @@ export default async function SitesPage(props: SitesPageProps) {
function formatSize(mb: number, type: string): string {
if (type === "local") {
return "--"; // because we are not able to track the data use in a local site right now
return "-"; // because we are not able to track the data use in a local site right now
}
if (mb >= 1024 * 1024) {
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;

View File

@@ -0,0 +1,58 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel
} from "@tanstack/react-table";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import { Button } from "@app/components/ui/button";
import { useState } from "react";
import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination";
import { Plus, Search } from "lucide-react";
import { DataTable } from "@app/components/ui/data-table";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
addApiKey?: () => void;
}
export function ApiKeysDataTable<TData, TValue>({
addApiKey,
columns,
data
}: DataTableProps<TData, TValue>) {
return (
<DataTable
columns={columns}
data={data}
title="API Keys"
searchPlaceholder="Search API keys..."
searchColumn="name"
onAdd={addApiKey}
addButtonText="Generate API Key"
/>
);
}

View File

@@ -0,0 +1,199 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { ColumnDef } from "@tanstack/react-table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import moment from "moment";
import { ApiKeysDataTable } from "./ApiKeysDataTable";
export type ApiKeyRow = {
id: string;
key: string;
name: string;
createdAt: string;
};
type ApiKeyTableProps = {
apiKeys: ApiKeyRow[];
};
export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
const router = useRouter();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selected, setSelected] = useState<ApiKeyRow | null>(null);
const [rows, setRows] = useState<ApiKeyRow[]>(apiKeys);
const api = createApiClient(useEnvContext());
const deleteSite = (apiKeyId: string) => {
api.delete(`/api-key/${apiKeyId}`)
.catch((e) => {
console.error("Error deleting API key", e);
toast({
variant: "destructive",
title: "Error deleting API key",
description: formatAxiosError(e, "Error deleting API key")
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
const newRows = rows.filter((row) => row.id !== apiKeyId);
setRows(newRows);
});
};
const columns: ColumnDef<ApiKeyRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const apiKeyROw = row.original;
const router = useRouter();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelected(apiKeyROw);
}}
>
<span>View settings</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setSelected(apiKeyROw);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
},
{
accessorKey: "name",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "key",
header: "Key",
cell: ({ row }) => {
const r = row.original;
return <span className="font-mono">{r.key}</span>;
}
},
{
accessorKey: "createdAt",
header: "Created At",
cell: ({ row }) => {
const r = row.original;
return <span>{moment(r.createdAt).format("lll")} </span>;
}
},
{
id: "actions",
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex items-center justify-end">
<Link href={`/admin/api-keys/${r.id}`}>
<Button variant={"outlinePrimary"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
}
}
];
return (
<>
{selected && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelected(null);
}}
dialog={
<div className="space-y-4">
<p>
Are you sure you want to remove the API key{" "}
<b>{selected?.name || selected?.id}</b>?
</p>
<p>
<b>
Once removed, the API key will no longer be
able to be used.
</b>
</p>
<p>
To confirm, please type the name of the API key
below.
</p>
</div>
}
buttonText="Confirm Delete API Key"
onConfirm={async () => deleteSite(selected!.id)}
string={selected.name}
title="Delete API Key"
/>
)}
<ApiKeysDataTable
columns={columns}
data={rows}
addApiKey={() => {
router.push(`/admin/api-keys/create`);
}}
/>
</>
);
}

View File

@@ -0,0 +1,62 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import { SidebarSettings } from "@app/components/SidebarSettings";
import Link from "next/link";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import { GetApiKeyResponse } from "@server/routers/apiKeys";
import ApiKeyProvider from "@app/providers/ApiKeyProvider";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
interface SettingsLayoutProps {
children: React.ReactNode;
params: Promise<{ apiKeyId: string }>;
}
export default async function SettingsLayout(props: SettingsLayoutProps) {
const params = await props.params;
const { children } = props;
let apiKey = null;
try {
const res = await internal.get<AxiosResponse<GetApiKeyResponse>>(
`/api-key/${params.apiKeyId}`,
await authCookieHeader()
);
apiKey = res.data.data;
} catch (e) {
console.error(e);
redirect(`/admin/api-keys`);
}
const navItems = [
{
title: "Permissions",
href: "/admin/api-keys/{apiKeyId}/permissions"
}
];
return (
<>
<SettingsSectionTitle title={`${apiKey?.name} Settings`} />
<ApiKeyProvider apiKey={apiKey}>
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</ApiKeyProvider>
</>
);
}

View File

@@ -0,0 +1,13 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { redirect } from "next/navigation";
export default async function ApiKeysPage(props: {
params: Promise<{ apiKeyId: string }>;
}) {
const params = await props.params;
redirect(`/admin/api-keys/${params.apiKeyId}/permissions`);
}

View File

@@ -0,0 +1,139 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { ListApiKeyActionsResponse } from "@server/routers/apiKeys";
import { AxiosResponse } from "axios";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
export default function Page() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const { apiKeyId } = useParams();
const [loadingPage, setLoadingPage] = useState<boolean>(true);
const [selectedPermissions, setSelectedPermissions] = useState<
Record<string, boolean>
>({});
const [loadingSavePermissions, setLoadingSavePermissions] =
useState<boolean>(false);
useEffect(() => {
async function load() {
setLoadingPage(true);
const res = await api
.get<
AxiosResponse<ListApiKeyActionsResponse>
>(`/api-key/${apiKeyId}/actions`)
.catch((e) => {
toast({
variant: "destructive",
title: "Error loading API key actions",
description: formatAxiosError(
e,
"Error loading API key actions"
)
});
});
if (res && res.status === 200) {
const data = res.data.data;
for (const action of data.actions) {
setSelectedPermissions((prev) => ({
...prev,
[action.actionId]: true
}));
}
}
setLoadingPage(false);
}
load();
}, []);
async function savePermissions() {
setLoadingSavePermissions(true);
const actionsRes = await api
.post(`/api-key/${apiKeyId}/actions`, {
actionIds: Object.keys(selectedPermissions).filter(
(key) => selectedPermissions[key]
)
})
.catch((e) => {
console.error("Error setting permissions", e);
toast({
variant: "destructive",
title: "Error setting permissions",
description: formatAxiosError(e)
});
});
if (actionsRes && actionsRes.status === 200) {
toast({
title: "Permissions updated",
description: "The permissions have been updated."
});
}
setLoadingSavePermissions(false);
}
return (
<>
{!loadingPage && (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Permissions
</SettingsSectionTitle>
<SettingsSectionDescription>
Determine what this API key can do
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<PermissionsSelectBox
selectedPermissions={selectedPermissions}
onChange={setSelectedPermissions}
root={true}
/>
<SettingsSectionFooter>
<Button
onClick={async () => {
await savePermissions();
}}
loading={loadingSavePermissions}
disabled={loadingSavePermissions}
>
Save Permissions
</Button>
</SettingsSectionFooter>
</SettingsSectionBody>
</SettingsSection>
</SettingsContainer>
)}
</>
);
}

View File

@@ -0,0 +1,402 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import { z } from "zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Input } from "@app/components/ui/input";
import { InfoIcon } from "lucide-react";
import { Button } from "@app/components/ui/button";
import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { AxiosResponse } from "axios";
import { useParams, useRouter } from "next/navigation";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import Link from "next/link";
import {
CreateOrgApiKeyBody,
CreateOrgApiKeyResponse
} from "@server/routers/apiKeys";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard";
import moment from "moment";
import CopyTextBox from "@app/components/CopyTextBox";
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
const createFormSchema = z.object({
name: z
.string()
.min(2, {
message: "Name must be at least 2 characters."
})
.max(255, {
message: "Name must not be longer than 255 characters."
})
});
type CreateFormValues = z.infer<typeof createFormSchema>;
const copiedFormSchema = z
.object({
copied: z.boolean()
})
.refine(
(data) => {
return data.copied;
},
{
message: "You must confirm that you have copied the API key.",
path: ["copied"]
}
);
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
export default function Page() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const [loadingPage, setLoadingPage] = useState(true);
const [createLoading, setCreateLoading] = useState(false);
const [apiKey, setApiKey] = useState<CreateOrgApiKeyResponse | null>(null);
const [selectedPermissions, setSelectedPermissions] = useState<
Record<string, boolean>
>({});
const form = useForm<CreateFormValues>({
resolver: zodResolver(createFormSchema),
defaultValues: {
name: ""
}
});
const copiedForm = useForm<CopiedFormValues>({
resolver: zodResolver(copiedFormSchema),
defaultValues: {
copied: false
}
});
async function onSubmit(data: CreateFormValues) {
setCreateLoading(true);
let payload: CreateOrgApiKeyBody = {
name: data.name
};
const res = await api
.put<AxiosResponse<CreateOrgApiKeyResponse>>(`/api-key`, payload)
.catch((e) => {
toast({
variant: "destructive",
title: "Error creating API key",
description: formatAxiosError(e)
});
});
if (res && res.status === 201) {
const data = res.data.data;
console.log({
actionIds: Object.keys(selectedPermissions).filter(
(key) => selectedPermissions[key]
)
});
const actionsRes = await api
.post(`/api-key/${data.apiKeyId}/actions`, {
actionIds: Object.keys(selectedPermissions).filter(
(key) => selectedPermissions[key]
)
})
.catch((e) => {
console.error("Error setting permissions", e);
toast({
variant: "destructive",
title: "Error setting permissions",
description: formatAxiosError(e)
});
});
if (actionsRes) {
setApiKey(data);
}
}
setCreateLoading(false);
}
async function onCopiedSubmit(data: CopiedFormValues) {
if (!data.copied) {
return;
}
router.push(`/admin/api-keys`);
}
useEffect(() => {
const load = async () => {
setLoadingPage(false);
};
load();
}, []);
return (
<>
<div className="flex justify-between">
<HeaderTitle
title="Generate API Key"
description="Generate a new root access API key"
/>
<Button
variant="outline"
onClick={() => {
router.push(`/admin/api-keys`);
}}
>
See All API Keys
</Button>
</div>
{!loadingPage && (
<div>
<SettingsContainer>
{!apiKey && (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
API Key Information
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
className="space-y-4"
id="create-site-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
Name
</FormLabel>
<FormControl>
<Input
autoComplete="off"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Permissions
</SettingsSectionTitle>
<SettingsSectionDescription>
Determine what this API key can do
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<PermissionsSelectBox
root={true}
selectedPermissions={
selectedPermissions
}
onChange={setSelectedPermissions}
/>
</SettingsSectionBody>
</SettingsSection>
</>
)}
{apiKey && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Your API Key
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={2}>
<InfoSection>
<InfoSectionTitle>
Name
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard
text={apiKey.name}
/>
</InfoSectionContent>
</InfoSection>
<InfoSection>
<InfoSectionTitle>
Created
</InfoSectionTitle>
<InfoSectionContent>
{moment(
apiKey.createdAt
).format("lll")}
</InfoSectionContent>
</InfoSection>
</InfoSections>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
Save Your API Key
</AlertTitle>
<AlertDescription>
You will only be able to see this
once. Make sure to copy it to a
secure place.
</AlertDescription>
</Alert>
<h4 className="font-semibold">
Your API key is:
</h4>
<CopyTextBox
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
/>
<Form {...copiedForm}>
<form
className="space-y-4"
id="copied-form"
>
<FormField
control={copiedForm.control}
name="copied"
render={({ field }) => (
<FormItem>
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
defaultChecked={
copiedForm.getValues(
"copied"
) as boolean
}
onCheckedChange={(
e
) => {
copiedForm.setValue(
"copied",
e as boolean
);
}}
/>
<label
htmlFor="terms"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I have copied
the API key
</label>
</div>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionBody>
</SettingsSection>
)}
</SettingsContainer>
<div className="flex justify-end space-x-2 mt-8">
{!apiKey && (
<Button
type="button"
variant="outline"
disabled={createLoading || apiKey !== null}
onClick={() => {
router.push(`/admin/api-keys`);
}}
>
Cancel
</Button>
)}
{!apiKey && (
<Button
type="button"
loading={createLoading}
disabled={createLoading || apiKey !== null}
onClick={() => {
form.handleSubmit(onSubmit)();
}}
>
Generate
</Button>
)}
{apiKey && (
<Button
type="button"
onClick={() => {
copiedForm.handleSubmit(onCopiedSubmit)();
}}
>
Done
</Button>
)}
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,46 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { ListRootApiKeysResponse } from "@server/routers/apiKeys";
import ApiKeysTable, { ApiKeyRow } from "./ApiKeysTable";
type ApiKeyPageProps = {};
export const dynamic = "force-dynamic";
export default async function ApiKeysPage(props: ApiKeyPageProps) {
let apiKeys: ListRootApiKeysResponse["apiKeys"] = [];
try {
const res = await internal.get<AxiosResponse<ListRootApiKeysResponse>>(
`/api-keys`,
await authCookieHeader()
);
apiKeys = res.data.data.apiKeys;
} catch (e) {}
const rows: ApiKeyRow[] = apiKeys.map((key) => {
return {
name: key.name,
id: key.apiKeyId,
key: `${key.apiKeyId}••••••••••••••••••••${key.lastChars}`,
createdAt: key.createdAt
};
});
return (
<>
<SettingsSectionTitle
title="Manage API Keys"
description="API keys are used to authenticate with the integration API"
/>
<ApiKeysTable apiKeys={rows} />
</>
);
}

View File

@@ -44,6 +44,7 @@ export default function IdpTable({ idps }: Props) {
title: "Success",
description: "Identity provider deleted successfully"
});
setIsDeleteModalOpen(false);
router.refresh();
} catch (e) {
toast({
@@ -153,22 +154,6 @@ export default function IdpTable({ idps }: Props) {
);
}
},
{
accessorKey: "orgCount",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Organization Policies
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "actions",
cell: ({ row }) => {

View File

@@ -41,6 +41,8 @@ import {
InfoSectionTitle
} from "@app/components/InfoSection";
import CopyToClipboard from "@app/components/CopyToClipboard";
import { Badge } from "@app/components/ui/badge";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
const GeneralFormSchema = z.object({
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
@@ -66,6 +68,7 @@ export default function GeneralPage() {
const { idpId } = useParams();
const [loading, setLoading] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const { isUnlocked } = useLicenseStatusContext();
const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
@@ -161,96 +164,42 @@ export default function GeneralPage() {
}
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Information
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the basic information for your identity
provider
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>Redirect URL</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard text={redirectUrl} />
</InfoSectionContent>
</InfoSection>
</InfoSections>
<Alert variant="neutral" className="">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
About Redirect URL
</AlertTitle>
<AlertDescription>
This is the URL to which users will be redirected
after authentication. You need to configure this URL
in your identity provider settings.
</AlertDescription>
</Alert>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
A display name for this identity
provider
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<SwitchInput
id="auto-provision-toggle"
label="Auto Provision Users"
defaultChecked={form.getValues(
"autoProvision"
)}
onCheckedChange={(checked) => {
form.setValue("autoProvision", checked);
}}
/>
<span className="text-sm text-muted-foreground">
When enabled, users will be automatically
created in the system upon first login using
this identity provider.
</span>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSectionGrid cols={2}>
<>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
OAuth2/OIDC Configuration
General Information
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the OAuth2/OIDC provider endpoints and
credentials
Configure the basic information for your identity
provider
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<InfoSections cols={3}>
<InfoSection>
<InfoSectionTitle>
Redirect URL
</InfoSectionTitle>
<InfoSectionContent>
<CopyToClipboard text={redirectUrl} />
</InfoSectionContent>
</InfoSection>
</InfoSections>
<Alert variant="neutral" className="">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
About Redirect URL
</AlertTitle>
<AlertDescription>
This is the URL to which users will be
redirected after authentication. You need to
configure this URL in your identity provider
settings.
</AlertDescription>
</Alert>
<SettingsSectionForm>
<Form {...form}>
<form
@@ -260,220 +209,306 @@ export default function GeneralPage() {
>
<FormField
control={form.control}
name="clientId"
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Client ID</FormLabel>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The OAuth2 client ID from
your identity provider
A display name for this
identity provider
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>
Client Secret
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
The OAuth2 client secret
from your identity provider
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="authUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
Authorization URL
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The OAuth2 authorization
endpoint URL
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tokenUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Token URL</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The OAuth2 token endpoint
URL
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Token Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure how to extract user information from the
ID token
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
About JMESPath
</AlertTitle>
<AlertDescription>
The paths below use JMESPath syntax
to extract values from the ID token.
<a
href="https://jmespath.org"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center"
<div className="flex items-start mb-0">
<SwitchInput
id="auto-provision-toggle"
label="Auto Provision Users"
defaultChecked={form.getValues(
"autoProvision"
)}
disabled={!isUnlocked()}
onCheckedChange={(checked) => {
form.setValue(
"autoProvision",
checked
);
}}
/>
{!isUnlocked() && (
<Badge
variant="outlinePrimary"
className="ml-2"
>
Learn more about JMESPath{" "}
<ExternalLink className="ml-1 h-4 w-4" />
</a>
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="identifierPath"
render={({ field }) => (
<FormItem>
<FormLabel>
Identifier Path
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the user
identifier in the ID token
</FormDescription>
<FormMessage />
</FormItem>
Professional
</Badge>
)}
/>
<FormField
control={form.control}
name="emailPath"
render={({ field }) => (
<FormItem>
<FormLabel>
Email Path (Optional)
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the user's
email in the ID token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="namePath"
render={({ field }) => (
<FormItem>
<FormLabel>
Name Path (Optional)
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the user's
name in the ID token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scopes"
render={({ field }) => (
<FormItem>
<FormLabel>Scopes</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Space-separated list of
OAuth2 scopes to request
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<span className="text-sm text-muted-foreground">
When enabled, users will be
automatically created in the system upon
first login with the ability to map
users to roles and organizations.
</span>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="general-settings-form"
loading={loading}
disabled={loading}
>
Save Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsSectionGrid>
</SettingsContainer>
<SettingsSectionGrid cols={2}>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
OAuth2/OIDC Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the OAuth2/OIDC provider endpoints and
credentials
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="clientId"
render={({ field }) => (
<FormItem>
<FormLabel>
Client ID
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The OAuth2 client ID
from your identity
provider
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientSecret"
render={({ field }) => (
<FormItem>
<FormLabel>
Client Secret
</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
</FormControl>
<FormDescription>
The OAuth2 client secret
from your identity
provider
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="authUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
Authorization URL
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The OAuth2 authorization
endpoint URL
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="tokenUrl"
render={({ field }) => (
<FormItem>
<FormLabel>
Token URL
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The OAuth2 token
endpoint URL
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Token Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure how to extract user information from
the ID token
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<Alert variant="neutral">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
About JMESPath
</AlertTitle>
<AlertDescription>
The paths below use JMESPath
syntax to extract values from
the ID token.
<a
href="https://jmespath.org"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center"
>
Learn more about JMESPath{" "}
<ExternalLink className="ml-1 h-4 w-4" />
</a>
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="identifierPath"
render={({ field }) => (
<FormItem>
<FormLabel>
Identifier Path
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the user
identifier in the ID
token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emailPath"
render={({ field }) => (
<FormItem>
<FormLabel>
Email Path (Optional)
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the
user's email in the ID
token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="namePath"
render={({ field }) => (
<FormItem>
<FormLabel>
Name Path (Optional)
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The JMESPath to the
user's name in the ID
token
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="scopes"
render={({ field }) => (
<FormItem>
<FormLabel>
Scopes
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Space-separated list of
OAuth2 scopes to request
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
</SettingsSectionGrid>
</SettingsContainer>
<div className="flex justify-end mt-8">
<Button
type="submit"
form="general-settings-form"
loading={loading}
disabled={loading}
>
Save General Settings
</Button>
</div>
</>
);
}

View File

@@ -4,6 +4,7 @@ import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { authCookieHeader } from "@app/lib/api/cookies";
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import { ProfessionalContentOverlay } from "@app/components/ProfessionalContentOverlay";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
@@ -35,10 +36,15 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
redirect("/admin/idp");
}
const navItems = [
const navItems: HorizontalTabs = [
{
title: "General",
href: `/admin/idp/${params.idpId}/general`
},
{
title: "Organization Policies",
href: `/admin/idp/${params.idpId}/policies`,
showProfessional: true
}
];

View File

@@ -0,0 +1,33 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
onAdd: () => void;
}
export function PolicyDataTable<TData, TValue>({
columns,
data,
onAdd
}: DataTableProps<TData, TValue>) {
return (
<DataTable
columns={columns}
data={data}
title="Organization Policies"
searchPlaceholder="Search organization policies..."
searchColumn="orgId"
addButtonText="Add Organization Policy"
onAdd={onAdd}
/>
);
}

View File

@@ -0,0 +1,159 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Button } from "@app/components/ui/button";
import {
ArrowUpDown,
Trash2,
MoreHorizontal,
Pencil,
ArrowRight
} from "lucide-react";
import { PolicyDataTable } from "./PolicyDataTable";
import { Badge } from "@app/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import Link from "next/link";
import { InfoPopup } from "@app/components/ui/info-popup";
export interface PolicyRow {
orgId: string;
roleMapping?: string;
orgMapping?: string;
}
interface Props {
policies: PolicyRow[];
onDelete: (orgId: string) => void;
onAdd: () => void;
onEdit: (policy: PolicyRow) => void;
}
export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props) {
const columns: ColumnDef<PolicyRow>[] = [
{
id: "dots",
cell: ({ row }) => {
const r = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
onDelete(r.orgId);
}}
>
<span className="text-red-500">Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
},
{
accessorKey: "orgId",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Organization ID
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "roleMapping",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Role Mapping
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const mapping = row.original.roleMapping;
return mapping ? (
<InfoPopup
text={mapping.length > 50 ? `${mapping.substring(0, 50)}...` : mapping}
info={mapping}
/>
) : (
"--"
);
}
},
{
accessorKey: "orgMapping",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Organization Mapping
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const mapping = row.original.orgMapping;
return mapping ? (
<InfoPopup
text={mapping.length > 50 ? `${mapping.substring(0, 50)}...` : mapping}
info={mapping}
/>
) : (
"--"
);
}
},
{
id: "actions",
cell: ({ row }) => {
const policy = row.original;
return (
<div className="flex items-center justify-end">
<Button
variant={"outlinePrimary"}
className="ml-2"
onClick={() => onEdit(policy)}
>
Edit
</Button>
</div>
);
}
}
];
return <PolicyDataTable columns={columns} data={policies} onAdd={onAdd} />;
}

View File

@@ -0,0 +1,645 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, ExternalLink, CheckIcon } from "lucide-react";
import PolicyTable, { PolicyRow } from "./PolicyTable";
import { AxiosResponse } from "axios";
import { ListOrgsResponse } from "@server/routers/org";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import { CaretSortIcon } from "@radix-ui/react-icons";
import Link from "next/link";
import { Textarea } from "@app/components/ui/textarea";
import { InfoPopup } from "@app/components/ui/info-popup";
import { GetIdpResponse } from "@server/routers/idp";
import {
SettingsContainer,
SettingsSection,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionFooter,
SettingsSectionForm
} from "@app/components/Settings";
type Organization = {
orgId: string;
name: string;
};
const policyFormSchema = z.object({
orgId: z.string().min(1, { message: "Organization is required" }),
roleMapping: z.string().optional(),
orgMapping: z.string().optional()
});
const defaultMappingsSchema = z.object({
defaultRoleMapping: z.string().optional(),
defaultOrgMapping: z.string().optional()
});
type PolicyFormValues = z.infer<typeof policyFormSchema>;
type DefaultMappingsValues = z.infer<typeof defaultMappingsSchema>;
export default function PoliciesPage() {
const { env } = useEnvContext();
const api = createApiClient({ env });
const router = useRouter();
const { idpId } = useParams();
const [pageLoading, setPageLoading] = useState(true);
const [addPolicyLoading, setAddPolicyLoading] = useState(false);
const [editPolicyLoading, setEditPolicyLoading] = useState(false);
const [deletePolicyLoading, setDeletePolicyLoading] = useState(false);
const [updateDefaultMappingsLoading, setUpdateDefaultMappingsLoading] =
useState(false);
const [policies, setPolicies] = useState<PolicyRow[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingPolicy, setEditingPolicy] = useState<PolicyRow | null>(null);
const form = useForm<PolicyFormValues>({
resolver: zodResolver(policyFormSchema),
defaultValues: {
orgId: "",
roleMapping: "",
orgMapping: ""
}
});
const defaultMappingsForm = useForm<DefaultMappingsValues>({
resolver: zodResolver(defaultMappingsSchema),
defaultValues: {
defaultRoleMapping: "",
defaultOrgMapping: ""
}
});
const loadIdp = async () => {
try {
const res = await api.get<AxiosResponse<GetIdpResponse>>(
`/idp/${idpId}`
);
if (res.status === 200) {
const data = res.data.data;
defaultMappingsForm.reset({
defaultRoleMapping: data.idp.defaultRoleMapping || "",
defaultOrgMapping: data.idp.defaultOrgMapping || ""
});
}
} catch (e) {
toast({
title: "Error",
description: formatAxiosError(e),
variant: "destructive"
});
}
};
const loadPolicies = async () => {
try {
const res = await api.get(`/idp/${idpId}/org`);
if (res.status === 200) {
setPolicies(res.data.data.policies);
}
} catch (e) {
toast({
title: "Error",
description: formatAxiosError(e),
variant: "destructive"
});
}
};
const loadOrganizations = async () => {
try {
const res = await api.get<AxiosResponse<ListOrgsResponse>>("/orgs");
if (res.status === 200) {
const existingOrgIds = policies.map((p) => p.orgId);
const availableOrgs = res.data.data.orgs.filter(
(org) => !existingOrgIds.includes(org.orgId)
);
setOrganizations(availableOrgs);
}
} catch (e) {
toast({
title: "Error",
description: formatAxiosError(e),
variant: "destructive"
});
}
};
useEffect(() => {
async function load() {
setPageLoading(true);
await loadPolicies();
await loadIdp();
setPageLoading(false);
}
load();
}, [idpId]);
const onAddPolicy = async (data: PolicyFormValues) => {
setAddPolicyLoading(true);
try {
const res = await api.put(`/idp/${idpId}/org/${data.orgId}`, {
roleMapping: data.roleMapping,
orgMapping: data.orgMapping
});
if (res.status === 201) {
const newPolicy = {
orgId: data.orgId,
name:
organizations.find((org) => org.orgId === data.orgId)
?.name || "",
roleMapping: data.roleMapping,
orgMapping: data.orgMapping
};
setPolicies([...policies, newPolicy]);
toast({
title: "Success",
description: "Policy added successfully"
});
setShowAddDialog(false);
form.reset();
}
} catch (e) {
toast({
title: "Error",
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setAddPolicyLoading(false);
}
};
const onEditPolicy = async (data: PolicyFormValues) => {
if (!editingPolicy) return;
setEditPolicyLoading(true);
try {
const res = await api.post(
`/idp/${idpId}/org/${editingPolicy.orgId}`,
{
roleMapping: data.roleMapping,
orgMapping: data.orgMapping
}
);
if (res.status === 200) {
setPolicies(
policies.map((policy) =>
policy.orgId === editingPolicy.orgId
? {
...policy,
roleMapping: data.roleMapping,
orgMapping: data.orgMapping
}
: policy
)
);
toast({
title: "Success",
description: "Policy updated successfully"
});
setShowAddDialog(false);
setEditingPolicy(null);
form.reset();
}
} catch (e) {
toast({
title: "Error",
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setEditPolicyLoading(false);
}
};
const onDeletePolicy = async (orgId: string) => {
setDeletePolicyLoading(true);
try {
const res = await api.delete(`/idp/${idpId}/org/${orgId}`);
if (res.status === 200) {
setPolicies(
policies.filter((policy) => policy.orgId !== orgId)
);
toast({
title: "Success",
description: "Policy deleted successfully"
});
}
} catch (e) {
toast({
title: "Error",
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setDeletePolicyLoading(false);
}
};
const onUpdateDefaultMappings = async (data: DefaultMappingsValues) => {
setUpdateDefaultMappingsLoading(true);
try {
const res = await api.post(`/idp/${idpId}/oidc`, {
defaultRoleMapping: data.defaultRoleMapping,
defaultOrgMapping: data.defaultOrgMapping
});
if (res.status === 200) {
toast({
title: "Success",
description: "Default mappings updated successfully"
});
}
} catch (e) {
toast({
title: "Error",
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setUpdateDefaultMappingsLoading(false);
}
};
if (pageLoading) {
return null;
}
return (
<>
<SettingsContainer>
<Alert variant="neutral" className="mb-6">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
About Organization Policies
</AlertTitle>
<AlertDescription>
Organization policies are used to control access to
organizations based on the user's ID token. You can
specify JMESPath expressions to extract role and
organization information from the ID token. For more
information, see{" "}
<Link
href="https://docs.fossorial.io/Pangolin/Identity%20Providers/auto-provision"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
the documentation
<ExternalLink className="ml-1 h-4 w-4 inline" />
</Link>
</AlertDescription>
</Alert>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Default Mappings (Optional)
</SettingsSectionTitle>
<SettingsSectionDescription>
The default mappings are used when when there is not
an organization policy defined for an organization.
You can specify the default role and organization
mappings to fall back to here.
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<Form {...defaultMappingsForm}>
<form
onSubmit={defaultMappingsForm.handleSubmit(
onUpdateDefaultMappings
)}
id="policy-default-mappings-form"
className="space-y-4"
>
<div className="grid gap-6 md:grid-cols-2">
<FormField
control={defaultMappingsForm.control}
name="defaultRoleMapping"
render={({ field }) => (
<FormItem>
<FormLabel>
Default Role Mapping
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
JMESPath to extract role
information from the ID
token. The result of this
expression must return the
role name as defined in the
organization as a string.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={defaultMappingsForm.control}
name="defaultOrgMapping"
render={({ field }) => (
<FormItem>
<FormLabel>
Default Organization Mapping
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
JMESPath to extract
organization information
from the ID token. This
expression must return thr
org ID or true for the user
to be allowed to access the
organization.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
<SettingsSectionFooter>
<Button
type="submit"
form="policy-default-mappings-form"
loading={updateDefaultMappingsLoading}
>
Save Default Mappings
</Button>
</SettingsSectionFooter>
</SettingsSectionBody>
</SettingsSection>
<PolicyTable
policies={policies}
onDelete={onDeletePolicy}
onAdd={() => {
loadOrganizations();
form.reset({
orgId: "",
roleMapping: "",
orgMapping: ""
});
setEditingPolicy(null);
setShowAddDialog(true);
}}
onEdit={(policy) => {
setEditingPolicy(policy);
form.reset({
orgId: policy.orgId,
roleMapping: policy.roleMapping || "",
orgMapping: policy.orgMapping || ""
});
setShowAddDialog(true);
}}
/>
</SettingsContainer>
<Credenza
open={showAddDialog}
onOpenChange={(val) => {
setShowAddDialog(val);
setEditingPolicy(null);
form.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{editingPolicy
? "Edit Organization Policy"
: "Add Organization Policy"}
</CredenzaTitle>
<CredenzaDescription>
Configure access for an organization
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(
editingPolicy ? onEditPolicy : onAddPolicy
)}
className="space-y-4"
id="policy-form"
>
<FormField
control={form.control}
name="orgId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Organization</FormLabel>
{editingPolicy ? (
<Input {...field} disabled />
) : (
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? organizations.find(
(
org
) =>
org.orgId ===
field.value
)?.name
: "Select organization"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="Search org" />
<CommandList>
<CommandEmpty>
No org
found.
</CommandEmpty>
<CommandGroup>
{organizations.map(
(
org
) => (
<CommandItem
value={`${org.orgId}`}
key={
org.orgId
}
onSelect={() => {
form.setValue(
"orgId",
org.orgId
);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
org.orgId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
{
org.name
}
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="roleMapping"
render={({ field }) => (
<FormItem>
<FormLabel>
Role Mapping Path (Optional)
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
JMESPath to extract role
information from the ID token.
The result of this expression
must return the role name as
defined in the organization as a
string.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="orgMapping"
render={({ field }) => (
<FormItem>
<FormLabel>
Organization Mapping Path
(Optional)
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
JMESPath to extract organization
information from the ID token.
This expression must return the
org ID or true for the user to
be allowed to access the
organization.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Cancel</Button>
</CredenzaClose>
<Button
type="submit"
form="policy-form"
loading={
editingPolicy
? editPolicyLoading
: addPolicyLoading
}
disabled={
editingPolicy
? editPolicyLoading
: addPolicyLoading
}
>
{editingPolicy ? "Update Policy" : "Add Policy"}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
);
}

View File

@@ -35,6 +35,8 @@ import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, ExternalLink } from "lucide-react";
import { StrategySelect } from "@app/components/StrategySelect";
import { SwitchInput } from "@app/components/SwitchInput";
import { Badge } from "@app/components/ui/badge";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
const createIdpFormSchema = z.object({
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
@@ -73,6 +75,7 @@ export default function Page() {
const api = createApiClient({ env });
const router = useRouter();
const [createLoading, setCreateLoading] = useState(false);
const { isUnlocked } = useLicenseStatusContext();
const form = useForm<CreateIdpFormValues>({
resolver: zodResolver(createIdpFormSchema),
@@ -87,7 +90,7 @@ export default function Page() {
namePath: "name",
emailPath: "email",
scopes: "openid profile email",
autoProvision: true
autoProvision: false
}
});
@@ -182,24 +185,35 @@ export default function Page() {
)}
/>
<SwitchInput
id="auto-provision-toggle"
label="Auto Provision Users"
defaultChecked={form.getValues(
"autoProvision"
<div className="flex items-start mb-0">
<SwitchInput
id="auto-provision-toggle"
label="Auto Provision Users"
defaultChecked={form.getValues(
"autoProvision"
)}
disabled={!isUnlocked()}
onCheckedChange={(checked) => {
form.setValue(
"autoProvision",
checked
);
}}
/>
{!isUnlocked() && (
<Badge
variant="outlinePrimary"
className="ml-2"
>
Professional
</Badge>
)}
onCheckedChange={(checked) => {
form.setValue(
"autoProvision",
checked
);
}}
/>
</div>
<span className="text-sm text-muted-foreground">
When enabled, users will be
automatically created in the system upon
first login using this identity
provider.
first login with the ability to map
users to roles and organizations.
</span>
</form>
</Form>

View File

@@ -0,0 +1,147 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { Button } from "@app/components/ui/button";
import { Badge } from "@app/components/ui/badge";
import { LicenseKeyCache } from "@server/license/license";
import { ArrowUpDown } from "lucide-react";
import moment from "moment";
import CopyToClipboard from "@app/components/CopyToClipboard";
type LicenseKeysDataTableProps = {
licenseKeys: LicenseKeyCache[];
onDelete: (key: LicenseKeyCache) => void;
onCreate: () => void;
};
function obfuscateLicenseKey(key: string): string {
if (key.length <= 8) return key;
const firstPart = key.substring(0, 4);
const lastPart = key.substring(key.length - 4);
return `${firstPart}••••••••••••••••••••${lastPart}`;
}
export function LicenseKeysDataTable({
licenseKeys,
onDelete,
onCreate
}: LicenseKeysDataTableProps) {
const columns: ColumnDef<LicenseKeyCache>[] = [
{
accessorKey: "licenseKey",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
License Key
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const licenseKey = row.original.licenseKey;
return (
<CopyToClipboard
text={licenseKey}
displayText={obfuscateLicenseKey(licenseKey)}
/>
);
}
},
{
accessorKey: "valid",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Valid
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return row.original.valid ? "Yes" : "No";
}
},
{
accessorKey: "type",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Type
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const type = row.original.type;
const label =
type === "SITES" ? "Additional Sites" : "Host License";
const variant = type === "SITES" ? "secondary" : "default";
return row.original.valid ? (
<Badge variant={variant}>{label}</Badge>
) : null;
}
},
{
accessorKey: "numSites",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Number of Sites
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "delete",
cell: ({ row }) => (
<div className="flex items-center justify-end space-x-2">
<Button
variant="outlinePrimary"
onClick={() => onDelete(row.original)}
>
Delete
</Button>
</div>
)
}
];
return (
<DataTable
columns={columns}
data={licenseKeys}
title="License Keys"
searchPlaceholder="Search license keys..."
searchColumn="licenseKey"
onAdd={onCreate}
addButtonText="Add License Key"
/>
);
}

View File

@@ -0,0 +1,149 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { useState } from "react";
import { Button } from "@app/components/ui/button";
import { MinusCircle, PlusCircle } from "lucide-react";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
type SitePriceCalculatorProps = {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
mode: "license" | "additional-sites";
};
export function SitePriceCalculator({
isOpen,
onOpenChange,
mode
}: SitePriceCalculatorProps) {
const [siteCount, setSiteCount] = useState(3);
const pricePerSite = 5;
const licenseFlatRate = 125;
const incrementSites = () => {
setSiteCount((prev) => prev + 1);
};
const decrementSites = () => {
setSiteCount((prev) => (prev > 1 ? prev - 1 : 1));
};
const totalCost =
mode === "license"
? licenseFlatRate + siteCount * pricePerSite
: siteCount * pricePerSite;
return (
<Credenza open={isOpen} onOpenChange={onOpenChange}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{mode === "license"
? "Purchase License"
: "Purchase Additional Sites"}
</CredenzaTitle>
<CredenzaDescription>
Choose how many sites you want to{" "}
{mode === "license"
? "purchase a license for. You can always add more sites later."
: "add to your existing license."}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-6">
<div className="flex flex-col items-center space-y-4">
<div className="text-sm font-medium text-muted-foreground">
Number of Sites
</div>
<div className="flex items-center space-x-4">
<Button
variant="ghost"
size="icon"
onClick={decrementSites}
disabled={siteCount <= 1}
aria-label="Decrease site count"
>
<MinusCircle className="h-5 w-5" />
</Button>
<span className="text-3xl w-12 text-center">
{siteCount}
</span>
<Button
variant="ghost"
size="icon"
onClick={incrementSites}
aria-label="Increase site count"
>
<PlusCircle className="h-5 w-5" />
</Button>
</div>
</div>
<div className="border-t pt-4">
{mode === "license" && (
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
License fee:
</span>
<span className="font-medium">
${licenseFlatRate.toFixed(2)}
</span>
</div>
)}
<div className="flex justify-between items-center mt-2">
<span className="text-sm font-medium">
Price per site:
</span>
<span className="font-medium">
${pricePerSite.toFixed(2)}
</span>
</div>
<div className="flex justify-between items-center mt-2">
<span className="text-sm font-medium">
Number of sites:
</span>
<span className="font-medium">{siteCount}</span>
</div>
<div className="flex justify-between items-center mt-4 text-lg font-bold">
<span>Total:</span>
<span>${totalCost.toFixed(2)} / mo</span>
</div>
<p className="text-muted-foreground text-sm mt-2 text-center">
For the most up-to-date pricing, please visit
our{" "}
<a
href="https://docs.fossorial.io/pricing"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
pricing page
</a>
.
</p>
</div>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Cancel</Button>
</CredenzaClose>
<Button>Continue to Payment</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -0,0 +1,474 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { useState, useEffect } from "react";
import { LicenseKeyCache } from "@server/license/license";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { LicenseKeysDataTable } from "./LicenseKeysDataTable";
import { AxiosResponse } from "axios";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { useRouter } from "next/navigation";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import {
SettingsContainer,
SettingsSectionTitle as SSTitle,
SettingsSection,
SettingsSectionDescription,
SettingsSectionGrid,
SettingsSectionHeader,
SettingsSectionFooter
} from "@app/components/Settings";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Badge } from "@app/components/ui/badge";
import { Check, ShieldCheck, ShieldOff } from "lucide-react";
import CopyTextBox from "@app/components/CopyTextBox";
import { Progress } from "@app/components/ui/progress";
import { MinusCircle, PlusCircle } from "lucide-react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { SitePriceCalculator } from "./components/SitePriceCalculator";
import Link from "next/link";
const formSchema = z.object({
licenseKey: z
.string()
.nonempty({ message: "License key is required" })
.max(255)
});
function obfuscateLicenseKey(key: string): string {
if (key.length <= 8) return key;
const firstPart = key.substring(0, 4);
const lastPart = key.substring(key.length - 4);
return `${firstPart}••••••••••••••••••••${lastPart}`;
}
export default function LicensePage() {
const api = createApiClient(useEnvContext());
const [rows, setRows] = useState<LicenseKeyCache[]>([]);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedLicenseKey, setSelectedLicenseKey] =
useState<LicenseKeyCache | null>(null);
const router = useRouter();
const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext();
const [hostLicense, setHostLicense] = useState<string | null>(null);
const [isPurchaseModalOpen, setIsPurchaseModalOpen] = useState(false);
const [purchaseMode, setPurchaseMode] = useState<
"license" | "additional-sites"
>("license");
// Separate loading states for different actions
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [isActivatingLicense, setIsActivatingLicense] = useState(false);
const [isDeletingLicense, setIsDeletingLicense] = useState(false);
const [isRecheckingLicense, setIsRecheckingLicense] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
licenseKey: ""
}
});
useEffect(() => {
async function load() {
setIsInitialLoading(true);
await loadLicenseKeys();
setIsInitialLoading(false);
}
load();
}, []);
async function loadLicenseKeys() {
try {
const response =
await api.get<AxiosResponse<LicenseKeyCache[]>>(
"/license/keys"
);
const keys = response.data.data;
setRows(keys);
const hostKey = keys.find((key) => key.type === "LICENSE");
if (hostKey) {
setHostLicense(hostKey.licenseKey);
} else {
setHostLicense(null);
}
} catch (e) {
toast({
title: "Failed to load license keys",
description: formatAxiosError(
e,
"An error occurred loading license keys"
)
});
}
}
async function deleteLicenseKey(key: string) {
try {
setIsDeletingLicense(true);
const encodedKey = encodeURIComponent(key);
const res = await api.delete(`/license/${encodedKey}`);
if (res.data.data) {
updateLicenseStatus(res.data.data);
}
await loadLicenseKeys();
toast({
title: "License key deleted",
description: "The license key has been deleted"
});
setIsDeleteModalOpen(false);
} catch (e) {
toast({
title: "Failed to delete license key",
description: formatAxiosError(
e,
"An error occurred deleting license key"
)
});
} finally {
setIsDeletingLicense(false);
}
}
async function recheck() {
try {
setIsRecheckingLicense(true);
const res = await api.post(`/license/recheck`);
if (res.data.data) {
updateLicenseStatus(res.data.data);
}
await loadLicenseKeys();
toast({
title: "License keys rechecked",
description: "All license keys have been rechecked"
});
} catch (e) {
toast({
title: "Failed to recheck license keys",
description: formatAxiosError(
e,
"An error occurred rechecking license keys"
)
});
} finally {
setIsRecheckingLicense(false);
}
}
async function onSubmit(values: z.infer<typeof formSchema>) {
try {
setIsActivatingLicense(true);
const res = await api.post("/license/activate", {
licenseKey: values.licenseKey
});
if (res.data.data) {
updateLicenseStatus(res.data.data);
}
toast({
title: "License key activated",
description: "The license key has been successfully activated."
});
setIsCreateModalOpen(false);
form.reset();
await loadLicenseKeys();
} catch (e) {
toast({
variant: "destructive",
title: "Failed to activate license key",
description: formatAxiosError(
e,
"An error occurred while activating the license key."
)
});
} finally {
setIsActivatingLicense(false);
}
}
if (isInitialLoading) {
return null;
}
return (
<>
<SitePriceCalculator
isOpen={isPurchaseModalOpen}
onOpenChange={(val) => {
setIsPurchaseModalOpen(val);
}}
mode={purchaseMode}
/>
<Credenza
open={isCreateModalOpen}
onOpenChange={(val) => {
setIsCreateModalOpen(val);
form.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Activate License Key</CredenzaTitle>
<CredenzaDescription>
Enter a license key to activate it.
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="activate-license-form"
>
<FormField
control={form.control}
name="licenseKey"
render={({ field }) => (
<FormItem>
<FormLabel>License Key</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="activate-license-form"
loading={isActivatingLicense}
disabled={isActivatingLicense}
>
Activate License
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
{selectedLicenseKey && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedLicenseKey(null);
}}
dialog={
<div className="space-y-4">
<p>
Are you sure you want to delete the license key{" "}
<b>
{obfuscateLicenseKey(
selectedLicenseKey.licenseKey
)}
</b>
?
</p>
<p>
<b>
This will remove the license key and all
associated permissions. Any sites using this
license key will no longer be accessible.
</b>
</p>
<p>
To confirm, please type the license key below.
</p>
</div>
}
buttonText="Confirm Delete License Key"
onConfirm={async () =>
deleteLicenseKey(selectedLicenseKey.licenseKeyEncrypted)
}
string={selectedLicenseKey.licenseKey}
title="Delete License Key"
/>
)}
<SettingsSectionTitle
title="Manage License Status"
description="View and manage license keys in the system"
/>
<SettingsContainer>
<SettingsSectionGrid cols={2}>
<SettingsSection>
<SettingsSectionHeader>
<SSTitle>Host License</SSTitle>
<SettingsSectionDescription>
Manage the main license key for the host.
</SettingsSectionDescription>
</SettingsSectionHeader>
<div className="space-y-4">
<div className="flex items-center space-x-4">
{licenseStatus?.isLicenseValid ? (
<div className="space-y-2 text-green-500">
<div className="text-2xl flex items-center gap-2">
<Check />
Licensed
</div>
</div>
) : (
<div className="space-y-2">
<div className="text-2xl">
Not Licensed
</div>
</div>
)}
</div>
{licenseStatus?.hostId && (
<div className="space-y-2">
<div className="text-sm font-medium">
Host ID
</div>
<CopyTextBox text={licenseStatus.hostId} />
</div>
)}
{hostLicense && (
<div className="space-y-2">
<div className="text-sm font-medium">
License Key
</div>
<CopyTextBox
text={hostLicense}
displayText={obfuscateLicenseKey(
hostLicense
)}
/>
</div>
)}
</div>
<SettingsSectionFooter>
<Button
variant="outline"
onClick={recheck}
disabled={isRecheckingLicense}
loading={isRecheckingLicense}
>
Recheck All Keys
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SSTitle>Sites Usage</SSTitle>
<SettingsSectionDescription>
View the number of sites using this license.
</SettingsSectionDescription>
</SettingsSectionHeader>
<div className="space-y-4">
<div className="space-y-2">
<div className="text-2xl">
{licenseStatus?.usedSites || 0}{" "}
{licenseStatus?.usedSites === 1
? "site"
: "sites"}{" "}
in system
</div>
</div>
{licenseStatus?.maxSites && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{licenseStatus.usedSites || 0} of{" "}
{licenseStatus.maxSites} sites used
</span>
<span className="text-muted-foreground">
{Math.round(
((licenseStatus.usedSites ||
0) /
licenseStatus.maxSites) *
100
)}
%
</span>
</div>
<Progress
value={
((licenseStatus.usedSites || 0) /
licenseStatus.maxSites) *
100
}
className="h-5"
/>
</div>
)}
</div>
<SettingsSectionFooter>
{!licenseStatus?.isHostLicensed ? (
<>
<Button
onClick={() => {
setPurchaseMode("license");
setIsPurchaseModalOpen(true);
}}
>
Purchase License
</Button>
</>
) : (
<>
<Button
variant="outline"
onClick={() => {
setPurchaseMode("additional-sites");
setIsPurchaseModalOpen(true);
}}
>
Purchase Additional Sites
</Button>
</>
)}
</SettingsSectionFooter>
</SettingsSection>
</SettingsSectionGrid>
<LicenseKeysDataTable
licenseKeys={rows}
onDelete={(key) => {
setSelectedLicenseKey(key);
setIsDeleteModalOpen(true);
}}
onCreate={() => setIsCreateModalOpen(true)}
/>
</SettingsContainer>
</>
);
}

View File

@@ -15,6 +15,7 @@ import {
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
type ValidateOidcTokenParams = {
orgId: string;
@@ -33,6 +34,8 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { licenseStatus, isLicenseViolation } = useLicenseStatusContext();
useEffect(() => {
async function validate() {
setLoading(true);
@@ -43,6 +46,10 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
stateCookie: props.stateCookie
});
if (isLicenseViolation()) {
await new Promise((resolve) => setTimeout(resolve, 5000));
}
try {
const res = await api.post<
AxiosResponse<ValidateOidcUrlCallbackResponse>

View File

@@ -377,31 +377,37 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
index={
0
}
obscured
/>
<InputOTPSlot
index={
1
}
obscured
/>
<InputOTPSlot
index={
2
}
obscured
/>
<InputOTPSlot
index={
3
}
obscured
/>
<InputOTPSlot
index={
4
}
obscured
/>
<InputOTPSlot
index={
5
}
obscured
/>
</InputOTPGroup>
</InputOTP>

View File

@@ -0,0 +1,46 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
export default function LicenseViolation() {
const { licenseStatus } = useLicenseStatusContext();
if (!licenseStatus) return null;
// Show invalid license banner
if (licenseStatus.isHostLicensed && !licenseStatus.isLicenseValid) {
return (
<div className="fixed bottom-0 left-0 right-0 w-full bg-red-500 text-white p-4 text-center z-50">
<p>
Invalid or expired license keys detected. Follow license
terms to continue using all features.
</p>
</div>
);
}
// Show usage violation banner
if (
licenseStatus.maxSites &&
licenseStatus.usedSites &&
licenseStatus.usedSites > licenseStatus.maxSites
) {
return (
<div className="fixed bottom-0 left-0 right-0 w-full bg-yellow-500 text-black p-4 text-center z-50">
<p>
License Violation: This server is using{" "}
{licenseStatus.usedSites} sites which exceeds its licensed
limit of {licenseStatus.maxSites} sites. Follow license
terms to continue using all features.
</p>
</div>
);
}
return null;
}

View File

@@ -1,25 +1,17 @@
import type { Metadata } from "next";
import "./globals.css";
import {
Figtree,
Inter,
Red_Hat_Display,
Red_Hat_Mono,
Red_Hat_Text,
Space_Grotesk
} from "next/font/google";
import { Inter } from "next/font/google";
import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider";
import { Separator } from "@app/components/ui/separator";
import { pullEnv } from "@app/lib/pullEnv";
import { BookOpenText, ExternalLink } from "lucide-react";
import Image from "next/image";
import SupportStatusProvider from "@app/providers/SupporterStatusProvider";
import { createApiClient, internal, priv } from "@app/lib/api";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { IsSupporterKeyVisibleResponse } from "@server/routers/supporterKey";
import SupporterMessage from "./components/SupporterMessage";
import LicenseStatusProvider from "@app/providers/LicenseStatusProvider";
import { GetLicenseStatusResponse } from "@server/routers/license";
import LicenseViolation from "./components/LicenseViolation";
export const metadata: Metadata = {
title: `Dashboard - Pangolin`,
@@ -48,6 +40,12 @@ export default async function RootLayout({
supporterData.visible = res.data.data.visible;
supporterData.tier = res.data.data.tier;
const licenseStatusRes =
await priv.get<AxiosResponse<GetLicenseStatusResponse>>(
"/license/status"
);
const licenseStatus = licenseStatusRes.data.data;
return (
<html suppressHydrationWarning>
<body className={`${font.className} h-screen overflow-hidden`}>
@@ -58,14 +56,19 @@ export default async function RootLayout({
disableTransitionOnChange
>
<EnvProvider env={pullEnv()}>
<SupportStatusProvider supporterStatus={supporterData}>
{/* Main content */}
<div className="h-full flex flex-col">
<div className="flex-1 overflow-auto">
{children}
<LicenseStatusProvider licenseStatus={licenseStatus}>
<SupportStatusProvider
supporterStatus={supporterData}
>
{/* Main content */}
<div className="h-full flex flex-col">
<div className="flex-1 overflow-auto">
<LicenseViolation />
{children}
</div>
</div>
</div>
</SupportStatusProvider>
</SupportStatusProvider>
</LicenseStatusProvider>
</EnvProvider>
<Toaster />
</ThemeProvider>

View File

@@ -7,9 +7,19 @@ import {
Waypoints,
Combine,
Fingerprint,
Workflow
Workflow,
KeyRound,
TicketCheck
} from "lucide-react";
export const orgLangingNavItems: SidebarNavItem[] = [
{
title: "Overview",
href: "/{orgId}",
icon: <Home className="h-4 w-4" />
}
];
export const rootNavItems: SidebarNavItem[] = [
{
title: "Home",
@@ -61,6 +71,12 @@ export const orgNavItems: SidebarNavItem[] = [
href: "/{orgId}/settings/share-links",
icon: <LinkIcon className="h-4 w-4" />
},
{
title: "API Keys",
href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="h-4 w-4" />,
showProfessional: true
},
{
title: "Settings",
href: "/{orgId}/settings/general",
@@ -74,9 +90,20 @@ export const adminNavItems: SidebarNavItem[] = [
href: "/admin/users",
icon: <Users className="h-4 w-4" />
},
{
title: "API Keys",
href: "/admin/api-keys",
icon: <KeyRound className="h-4 w-4" />,
showProfessional: true
},
{
title: "Identity Providers",
href: "/admin/idp",
icon: <Fingerprint className="h-4 w-4" />
},
{
title: "License",
href: "/admin/license",
icon: <TicketCheck className="h-4 w-4" />
}
];

View File

@@ -7,6 +7,10 @@ import { Metadata } from "next";
import { redirect } from "next/navigation";
import { cache } from "react";
import { rootNavItems } from "../navigation";
import { ListUserOrgsResponse } from "@server/routers/org";
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/lib/api/cookies";
export const metadata: Metadata = {
title: `Setup - Pangolin`,
@@ -33,10 +37,28 @@ export default async function SetupLayout({
redirect("/");
}
let orgs: ListUserOrgsResponse["orgs"] = [];
try {
const getOrgs = cache(async () =>
internal.get<AxiosResponse<ListUserOrgsResponse>>(
`/user/${user.userId}/orgs`,
await authCookieHeader()
)
);
const res = await getOrgs();
if (res && res.data.data.orgs) {
orgs = res.data.data.orgs;
}
} catch (e) {}
return (
<>
<UserProvider user={user}>
<Layout navItems={rootNavItems} showBreadcrumbs={false}>
<Layout
navItems={rootNavItems}
showBreadcrumbs={false}
orgs={orgs}
>
<div className="w-full max-w-2xl mx-auto md:mt-32 mt-4">
{children}
</div>

View File

@@ -80,6 +80,9 @@ export default function StepperForm() {
};
const checkOrgIdAvailability = useCallback(async (value: string) => {
if (loading) {
return;
}
try {
const res = await api.get(`/org/checkId`, {
params: {

View File

@@ -37,8 +37,8 @@ export function Breadcrumbs() {
// label = "Roles";
// } else if (segment === "invitations") {
// label = "Invitations";
// } else if (segment === "connectivity") {
// label = "Connectivity";
// } else if (segment === "proxy") {
// label = "proxy";
// } else if (segment === "authentication") {
// label = "Authentication";
// }

View File

@@ -4,20 +4,26 @@ import { useState, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Copy, Check } from "lucide-react";
type CopyTextBoxProps = {
text?: string;
displayText?: string;
wrapText?: boolean;
outline?: boolean;
};
export default function CopyTextBox({
text = "",
displayText,
wrapText = false,
outline = true
}) {
}: CopyTextBoxProps) {
const [isCopied, setIsCopied] = useState(false);
const textRef = useRef<HTMLPreElement>(null);
const copyToClipboard = async () => {
if (textRef.current) {
try {
await navigator.clipboard.writeText(
textRef.current.textContent || ""
);
await navigator.clipboard.writeText(text);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (err) {
@@ -38,7 +44,7 @@ export default function CopyTextBox({
: "overflow-x-auto"
}`}
>
<code className="block w-full">{text}</code>
<code className="block w-full">{displayText || text}</code>
</pre>
<Button
variant="ghost"

View File

@@ -4,10 +4,11 @@ import { useState } from "react";
type CopyToClipboardProps = {
text: string;
displayText?: string;
isLink?: boolean;
};
const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) => {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
@@ -19,6 +20,8 @@ const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
}, 2000);
};
const displayValue = displayText ?? text;
return (
<div className="flex items-center space-x-2 max-w-full">
{isLink ? (
@@ -30,7 +33,7 @@ const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
style={{ maxWidth: "100%" }} // Ensures truncation works within parent
title={text} // Shows full text on hover
>
{text}
{displayValue}
</Link>
) : (
<span
@@ -44,7 +47,7 @@ const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
}}
title={text} // Full text tooltip
>
{text}
{displayValue}
</span>
)}
<button

View File

@@ -5,14 +5,19 @@ import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { cn } from "@app/lib/cn";
import { buttonVariants } from "@/components/ui/button";
import { Badge } from "@app/components/ui/badge";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
export type HorizontalTabs = Array<{
title: string;
href: string;
icon?: React.ReactNode;
showProfessional?: boolean;
}>;
interface HorizontalTabsProps {
children: React.ReactNode;
items: Array<{
title: string;
href: string;
icon?: React.ReactNode;
}>;
items: HorizontalTabs;
disabled?: boolean;
}
@@ -23,6 +28,7 @@ export function HorizontalTabs({
}: HorizontalTabsProps) {
const pathname = usePathname();
const params = useParams();
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
function hydrateHref(href: string) {
return href
@@ -30,7 +36,8 @@ export function HorizontalTabs({
.replace("{resourceId}", params.resourceId as string)
.replace("{niceId}", params.niceId as string)
.replace("{userId}", params.userId as string)
.replace("{clientId}", params.clientId as string);
.replace("{clientId}", params.clientId as string)
.replace("{apiKeyId}", params.apiKeyId as string);
}
return (
@@ -43,34 +50,47 @@ export function HorizontalTabs({
const isActive =
pathname.startsWith(hydratedHref) &&
!pathname.includes("create");
const isProfessional =
item.showProfessional && !isUnlocked();
const isDisabled =
disabled || (isProfessional && !isUnlocked());
return (
<Link
key={hydratedHref}
href={hydratedHref}
href={isProfessional ? "#" : hydratedHref}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap",
isActive
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground",
disabled && "cursor-not-allowed"
isDisabled && "cursor-not-allowed"
)}
onClick={
disabled
? (e) => e.preventDefault()
: undefined
}
tabIndex={disabled ? -1 : undefined}
aria-disabled={disabled}
onClick={(e) => {
if (isDisabled) {
e.preventDefault();
}
}}
tabIndex={isDisabled ? -1 : undefined}
aria-disabled={isDisabled}
>
{item.icon ? (
<div className="flex items-center space-x-2">
{item.icon}
<span>{item.title}</span>
</div>
) : (
item.title
)}
<div
className={cn(
"flex items-center space-x-2",
isDisabled && "opacity-60"
)}
>
{item.icon && item.icon}
<span>{item.title}</span>
{isProfessional && (
<Badge
variant="outlinePrimary"
className="ml-2"
>
Professional
</Badge>
)}
</div>
</Link>
);
})}

View File

@@ -161,14 +161,6 @@ export function Layout({
>
Documentation
</Link>
<Link
href="mailto:support@fossorial.io"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Support
</Link>
</div>
<div>
<ProfileIcon />

View File

@@ -0,0 +1,238 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { CheckboxWithLabel } from "@app/components/ui/checkbox";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
type PermissionsSelectBoxProps = {
root?: boolean;
selectedPermissions: Record<string, boolean>;
onChange: (updated: Record<string, boolean>) => void;
};
function getActionsCategories(root: boolean) {
const actionsByCategory: Record<string, Record<string, string>> = {
Organization: {
"Get Organization": "getOrg",
"Update Organization": "updateOrg",
"Get Organization User": "getOrgUser",
"List Organization Domains": "listOrgDomains",
"Check Org ID": "checkOrgId",
"List Orgs": "listOrgs"
},
Site: {
"Create Site": "createSite",
"Delete Site": "deleteSite",
"Get Site": "getSite",
"List Sites": "listSites",
"Update Site": "updateSite",
"List Allowed Site Roles": "listSiteRoles"
},
Resource: {
"Create Resource": "createResource",
"Delete Resource": "deleteResource",
"Get Resource": "getResource",
"List Resources": "listResources",
"Update Resource": "updateResource",
"List Resource Users": "listResourceUsers",
"Set Resource Users": "setResourceUsers",
"Set Allowed Resource Roles": "setResourceRoles",
"List Allowed Resource Roles": "listResourceRoles",
"Set Resource Password": "setResourcePassword",
"Set Resource Pincode": "setResourcePincode",
"Set Resource Email Whitelist": "setResourceWhitelist",
"Get Resource Email Whitelist": "getResourceWhitelist"
},
Target: {
"Create Target": "createTarget",
"Delete Target": "deleteTarget",
"Get Target": "getTarget",
"List Targets": "listTargets",
"Update Target": "updateTarget"
},
Role: {
"Create Role": "createRole",
"Delete Role": "deleteRole",
"Get Role": "getRole",
"List Roles": "listRoles",
"Update Role": "updateRole",
"List Allowed Role Resources": "listRoleResources"
},
User: {
"Invite User": "inviteUser",
"Remove User": "removeUser",
"List Users": "listUsers",
"Add User Role": "addUserRole"
},
"Access Token": {
"Generate Access Token": "generateAccessToken",
"Delete Access Token": "deleteAcessToken",
"List Access Tokens": "listAccessTokens"
},
"Resource Rule": {
"Create Resource Rule": "createResourceRule",
"Delete Resource Rule": "deleteResourceRule",
"List Resource Rules": "listResourceRules",
"Update Resource Rule": "updateResourceRule"
}
// "Newt": {
// "Create Newt": "createNewt"
// },
};
if (root) {
actionsByCategory["Organization"] = {
"Create Organization": "createOrg",
"Delete Organization": "deleteOrg",
"List API Keys": "listApiKeys",
"List API Key Actions": "listApiKeyActions",
"Set API Key Allowed Actions": "setApiKeyActions",
"Create API Key": "createApiKey",
"Delete API Key": "deleteApiKey",
...actionsByCategory["Organization"]
};
actionsByCategory["Identity Provider (IDP)"] = {
"Create IDP": "createIdp",
"Update IDP": "updateIdp",
"Delete IDP": "deleteIdp",
"List IDP": "listIdps",
"Get IDP": "getIdp",
"Create IDP Org Policy": "createIdpOrg",
"Delete IDP Org Policy": "deleteIdpOrg",
"List IDP Orgs": "listIdpOrgs",
"Update IDP Org": "updateIdpOrg"
};
}
return actionsByCategory;
}
export default function PermissionsSelectBox({
root,
selectedPermissions,
onChange
}: PermissionsSelectBoxProps) {
const actionsByCategory = getActionsCategories(root ?? false);
const togglePermission = (key: string, checked: boolean) => {
onChange({
...selectedPermissions,
[key]: checked
});
};
const areAllCheckedInCategory = (actions: Record<string, string>) => {
return Object.values(actions).every(
(action) => selectedPermissions[action]
);
};
const toggleAllInCategory = (
actions: Record<string, string>,
value: boolean
) => {
const updated = { ...selectedPermissions };
Object.values(actions).forEach((action) => {
updated[action] = value;
});
onChange(updated);
};
const allActions = Object.values(actionsByCategory).flatMap(Object.values);
const allPermissionsChecked = allActions.every(
(action) => selectedPermissions[action]
);
const toggleAllPermissions = (checked: boolean) => {
const updated: Record<string, boolean> = {};
allActions.forEach((action) => {
updated[action] = checked;
});
onChange(updated);
};
return (
<>
<div className="mb-4">
<CheckboxWithLabel
variant="outlinePrimarySquare"
id="toggle-all-permissions"
label="Allow All Permissions"
checked={allPermissionsChecked}
onCheckedChange={(checked) =>
toggleAllPermissions(checked as boolean)
}
/>
</div>
<InfoSections cols={5}>
{Object.entries(actionsByCategory).map(
([category, actions]) => {
const allChecked = areAllCheckedInCategory(actions);
return (
<InfoSection key={category}>
<InfoSectionTitle>{category}</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<CheckboxWithLabel
variant="outlinePrimarySquare"
id={`toggle-all-${category}`}
label="Allow All"
checked={allChecked}
onCheckedChange={(checked) =>
toggleAllInCategory(
actions,
checked as boolean
)
}
/>
{Object.entries(actions).map(
([label, value]) => (
<CheckboxWithLabel
variant="outlineSquare"
key={value}
id={value}
label={label}
checked={
!!selectedPermissions[
value
]
}
onCheckedChange={(
checked
) =>
togglePermission(
value,
checked as boolean
)
}
/>
)
)}
</div>
</InfoSectionContent>
</InfoSection>
);
}
)}
</InfoSections>
</>
);
}

View File

@@ -0,0 +1,42 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import { cn } from "@app/lib/cn";
type ProfessionalContentOverlayProps = {
children: React.ReactNode;
isProfessional?: boolean;
};
export function ProfessionalContentOverlay({
children,
isProfessional = false
}: ProfessionalContentOverlayProps) {
return (
<div
className={cn(
"relative",
isProfessional && "opacity-60 pointer-events-none"
)}
>
{isProfessional && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-50">
<div className="text-center p-6 bg-primary/10 rounded-lg">
<h3 className="text-lg font-semibold mb-2">
Professional Edition Required
</h3>
<p className="text-muted-foreground">
This feature is only available in the Professional
Edition.
</p>
</div>
</div>
)}
{children}
</div>
);
}

View File

@@ -3,7 +3,7 @@ export function SettingsContainer({ children }: { children: React.ReactNode }) {
}
export function SettingsSection({ children }: { children: React.ReactNode }) {
return <div className="border rounded-lg bg-card p-5">{children}</div>;
return <div className="border rounded-lg bg-card p-5 flex flex-col min-h-[200px]">{children}</div>;
}
export function SettingsSectionHeader({
@@ -47,7 +47,7 @@ export function SettingsSectionBody({
}: {
children: React.ReactNode;
}) {
return <div className="space-y-5">{children}</div>;
return <div className="space-y-5 flex-grow">{children}</div>;
}
export function SettingsSectionFooter({
@@ -55,7 +55,7 @@ export function SettingsSectionFooter({
}: {
children: React.ReactNode;
}) {
return <div className="flex justify-end space-x-4 mt-8">{children}</div>;
return <div className="flex justify-end space-x-2 mt-auto pt-8">{children}</div>;
}
export function SettingsSectionGrid({

View File

@@ -6,6 +6,8 @@ import { useParams, usePathname } from "next/navigation";
import { cn } from "@app/lib/cn";
import { ChevronDown, ChevronRight } from "lucide-react";
import { useUserContext } from "@app/hooks/useUserContext";
import { Badge } from "@app/components/ui/badge";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
export interface SidebarNavItem {
href: string;
@@ -13,6 +15,7 @@ export interface SidebarNavItem {
icon?: React.ReactNode;
children?: SidebarNavItem[];
autoExpand?: boolean;
showProfessional?: boolean;
}
export interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
@@ -36,6 +39,7 @@ export function SidebarNav({
const userId = params.userId as string;
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const clientId = params.clientId as string;
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const { user } = useUserContext();
@@ -97,7 +101,9 @@ export function SidebarNav({
const isActive = pathname.startsWith(hydratedHref);
const hasChildren = item.children && item.children.length > 0;
const isExpanded = expandedItems.has(hydratedHref);
const indent = level * 16; // Base indent for each level
const indent = level * 28; // Base indent for each level
const isProfessional = item.showProfessional && !isUnlocked();
const isDisabled = disabled || isProfessional;
return (
<div key={hydratedHref}>
@@ -112,34 +118,51 @@ export function SidebarNav({
)}
>
<Link
href={hydratedHref}
href={isProfessional ? "#" : hydratedHref}
className={cn(
"flex items-center w-full px-3 py-2",
isActive
? "text-primary font-medium"
: "text-muted-foreground group-hover:text-foreground",
disabled && "cursor-not-allowed opacity-60"
isDisabled && "cursor-not-allowed"
)}
onClick={(e) => {
if (disabled) {
if (isDisabled) {
e.preventDefault();
} else if (onItemClick) {
onItemClick();
}
}}
tabIndex={disabled ? -1 : undefined}
aria-disabled={disabled}
tabIndex={isDisabled ? -1 : undefined}
aria-disabled={isDisabled}
>
{item.icon && (
<span className="mr-3">{item.icon}</span>
<div
className={cn(
"flex items-center",
isDisabled && "opacity-60"
)}
>
{item.icon && (
<span className="mr-3">
{item.icon}
</span>
)}
{item.title}
</div>
{isProfessional && (
<Badge
variant="outlinePrimary"
className="ml-2"
>
Professional
</Badge>
)}
{item.title}
</Link>
{hasChildren && (
<button
onClick={() => toggleItem(hydratedHref)}
className="p-2 rounded-md text-muted-foreground hover:text-foreground cursor-pointer"
disabled={disabled}
disabled={isDisabled}
>
{isExpanded ? (
<ChevronDown className="h-5 w-5" />

View File

@@ -204,7 +204,7 @@ export default function SupporterStatus() {
Payments are processed via GitHub. Afterward, you
can retrieve your key on{" "}
<Link
href="https://supporters.dev.fossorial.io/"
href="https://supporters.fossorial.io/"
target="_blank"
rel="noopener noreferrer"
className="underline"

View File

@@ -7,6 +7,7 @@ interface SwitchComponentProps {
label: string;
description?: string;
defaultChecked?: boolean;
disabled?: boolean;
onCheckedChange: (checked: boolean) => void;
}
@@ -14,6 +15,7 @@ export function SwitchInput({
id,
label,
description,
disabled,
defaultChecked = false,
onCheckedChange
}: SwitchComponentProps) {
@@ -24,6 +26,7 @@ export function SwitchInput({
id={id}
defaultChecked={defaultChecked}
onCheckedChange={onCheckedChange}
disabled={disabled}
/>
<Label htmlFor={id}>{label}</Label>
</div>

View File

@@ -9,14 +9,15 @@ const badgeVariants = cva(
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
"border-transparent bg-primary text-primary-foreground",
outlinePrimary: "border-transparent bg-transparent border-primary text-primary",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
"border-transparent bg-secondary text-secondary-foreground",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
"border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground",
green: "border-transparent bg-green-300",
yellow: "border-transparent bg-yellow-300",
green: "border-transparent bg-green-500",
yellow: "border-transparent bg-yellow-500",
red: "border-transparent bg-red-300",
},
},

View File

@@ -8,8 +8,8 @@ import { cn } from "@app/lib/cn"
const InputOTP = React.forwardRef<
React.ElementRef<typeof OTPInput>,
React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof OTPInput> & { obscured?: boolean }
>(({ className, containerClassName, obscured = false, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn(
@@ -32,8 +32,8 @@ InputOTPGroup.displayName = "InputOTPGroup"
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
React.ComponentPropsWithoutRef<"div"> & { index: number; obscured?: boolean }
>(({ index, className, obscured = false, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
@@ -47,7 +47,7 @@ const InputOTPSlot = React.forwardRef<
)}
{...props}
>
{char}
{char && obscured ? "•" : char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />

View File

@@ -0,0 +1,30 @@
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@app/lib/cn";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"border relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@@ -0,0 +1,16 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { GetApiKeyResponse } from "@server/routers/apiKeys";
import { createContext } from "react";
interface ApiKeyContextType {
apiKey: GetApiKeyResponse;
updateApiKey: (updatedApiKey: Partial<GetApiKeyResponse>) => void;
}
const ApiKeyContext = createContext<ApiKeyContextType | undefined>(undefined);
export default ApiKeyContext;

View File

@@ -0,0 +1,20 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import { LicenseStatus } from "@server/license/license";
import { createContext } from "react";
type LicenseStatusContextType = {
licenseStatus: LicenseStatus | null;
updateLicenseStatus: (updatedSite: LicenseStatus) => void;
isLicenseViolation: () => boolean;
isUnlocked: () => boolean;
};
const LicenseStatusContext = createContext<
LicenseStatusContextType | undefined
>(undefined);
export default LicenseStatusContext;

View File

@@ -0,0 +1,17 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import ApiKeyContext from "@app/contexts/apiKeyContext";
import { useContext } from "react";
export function useApiKeyContext() {
const context = useContext(ApiKeyContext);
if (context === undefined) {
throw new Error(
"useApiKeyContext must be used within a ApiKeyProvider"
);
}
return context;
}

View File

@@ -0,0 +1,17 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
import LicenseStatusContext from "@app/contexts/licenseStatusContext";
import { useContext } from "react";
export function useLicenseStatusContext() {
const context = useContext(LicenseStatusContext);
if (context === undefined) {
throw new Error(
"useLicenseStatusContext must be used within an LicenseStatusProvider"
);
}
return context;
}

View File

@@ -0,0 +1,42 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import ApiKeyContext from "@app/contexts/apiKeyContext";
import { GetApiKeyResponse } from "@server/routers/apiKeys";
import { useState } from "react";
interface ApiKeyProviderProps {
children: React.ReactNode;
apiKey: GetApiKeyResponse;
}
export function ApiKeyProvider({ children, apiKey: ak }: ApiKeyProviderProps) {
const [apiKey, setApiKey] = useState<GetApiKeyResponse>(ak);
const updateApiKey = (updatedApiKey: Partial<GetApiKeyResponse>) => {
if (!apiKey) {
throw new Error("No API key to update");
}
setApiKey((prev) => {
if (!prev) {
return prev;
}
return {
...prev,
...updatedApiKey
};
});
};
return (
<ApiKeyContext.Provider value={{ apiKey, updateApiKey }}>
{children}
</ApiKeyContext.Provider>
);
}
export default ApiKeyProvider;

View File

@@ -0,0 +1,72 @@
// This file is licensed under the Fossorial Commercial License.
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
//
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
"use client";
import LicenseStatusContext from "@app/contexts/licenseStatusContext";
import { LicenseStatus } from "@server/license/license";
import { useState } from "react";
interface ProviderProps {
children: React.ReactNode;
licenseStatus: LicenseStatus | null;
}
export function LicenseStatusProvider({
children,
licenseStatus
}: ProviderProps) {
const [licenseStatusState, setLicenseStatusState] =
useState<LicenseStatus | null>(licenseStatus);
const updateLicenseStatus = (updatedLicenseStatus: LicenseStatus) => {
setLicenseStatusState((prev) => {
return {
...updatedLicenseStatus
};
});
};
const isUnlocked = () => {
if (licenseStatusState?.isHostLicensed) {
if (licenseStatusState?.isLicenseValid) {
return true;
}
}
return false;
};
const isLicenseViolation = () => {
if (
licenseStatusState?.isHostLicensed &&
!licenseStatusState?.isLicenseValid
) {
return true;
}
if (
licenseStatusState?.maxSites &&
licenseStatusState?.usedSites &&
licenseStatusState.usedSites > licenseStatusState.maxSites
) {
return true;
}
return false;
};
return (
<LicenseStatusContext.Provider
value={{
licenseStatus: licenseStatusState,
updateLicenseStatus,
isLicenseViolation,
isUnlocked
}}
>
{children}
</LicenseStatusContext.Provider>
);
}
export default LicenseStatusProvider;