Pull up downstream changes

This commit is contained in:
Owen
2025-07-13 21:57:24 -07:00
parent c679875273
commit 98a261e38c
108 changed files with 9799 additions and 2038 deletions

View File

@@ -0,0 +1,430 @@
"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 { useToast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import { formatAxiosError } from "@app/lib/api";
import { CreateDomainResponse } from "@server/routers/domain/createOrgDomain";
import { StrategySelect } from "@app/components/StrategySelect";
import { AxiosResponse } from "axios";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { InfoIcon, AlertTriangle } from "lucide-react";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { useOrgContext } from "@app/hooks/useOrgContext";
const formSchema = z.object({
baseDomain: z.string().min(1, "Domain is required"),
type: z.enum(["ns", "cname"])
});
type FormValues = z.infer<typeof formSchema>;
type CreateDomainFormProps = {
open: boolean;
setOpen: (open: boolean) => void;
onCreated?: (domain: CreateDomainResponse) => void;
};
export default function CreateDomainForm({
open,
setOpen,
onCreated
}: CreateDomainFormProps) {
const [loading, setLoading] = useState(false);
const [createdDomain, setCreatedDomain] =
useState<CreateDomainResponse | null>(null);
const api = createApiClient(useEnvContext());
const t = useTranslations();
const { toast } = useToast();
const { org } = useOrgContext();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
baseDomain: "",
type: "ns"
}
});
function reset() {
form.reset();
setLoading(false);
setCreatedDomain(null);
}
async function onSubmit(values: FormValues) {
setLoading(true);
try {
const response = await api.put<AxiosResponse<CreateDomainResponse>>(
`/org/${org.org.orgId}/domain`,
values
);
const domainData = response.data.data;
setCreatedDomain(domainData);
toast({
title: t("success"),
description: t("domainCreatedDescription")
});
onCreated?.(domainData);
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setLoading(false);
}
}
const domainType = form.watch("type");
const baseDomain = form.watch("baseDomain");
return (
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("domainAdd")}</CredenzaTitle>
<CredenzaDescription>
{t("domainAddDescription")}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{!createdDomain ? (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="create-domain-form"
>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<StrategySelect
options={[
{
id: "ns",
title: t(
"selectDomainTypeNsName"
),
description: t(
"selectDomainTypeNsDescription"
)
},
{
id: "cname",
title: t(
"selectDomainTypeCnameName"
),
description: t(
"selectDomainTypeCnameDescription"
)
}
]}
defaultValue={field.value}
onChange={field.onChange}
cols={1}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="baseDomain"
render={({ field }) => (
<FormItem>
<FormLabel>{t("domain")}</FormLabel>
<FormControl>
<Input
placeholder="example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
) : (
<div className="space-y-6">
<Alert variant="default">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
Add DNS Records
</AlertTitle>
<AlertDescription>
Add the following DNS records to your domain
provider to complete the setup.
</AlertDescription>
</Alert>
<div className="space-y-4">
{domainType === "ns" &&
createdDomain.nsRecords && (
<div>
<h3 className="font-medium mb-3">
NS Records
</h3>
<InfoSections cols={1}>
<InfoSection>
<InfoSectionTitle>
Record
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Type:
</span>
<span className="text-sm font-mono">
NS
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Name:
</span>
<span className="text-sm font-mono">
{baseDomain}
</span>
</div>
<span className="text-sm font-medium">
Value:
</span>
{createdDomain.nsRecords.map(
(
nsRecord,
index
) => (
<div
className="flex justify-between items-center"
key={
index
}
>
<CopyToClipboard
text={
nsRecord
}
/>
</div>
)
)}
</div>
</InfoSectionContent>
</InfoSection>
</InfoSections>
</div>
)}
{domainType === "cname" && (
<>
{createdDomain.cnameRecords &&
createdDomain.cnameRecords.length >
0 && (
<div>
<h3 className="font-medium mb-3">
CNAME Records
</h3>
<InfoSections cols={1}>
{createdDomain.cnameRecords.map(
(
cnameRecord,
index
) => (
<InfoSection
key={index}
>
<InfoSectionTitle>
Record{" "}
{index +
1}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Type:
</span>
<span className="text-sm font-mono">
CNAME
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Name:
</span>
<span className="text-sm font-mono">
{
cnameRecord.baseDomain
}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Value:
</span>
<CopyToClipboard
text={
cnameRecord.value
}
/>
</div>
</div>
</InfoSectionContent>
</InfoSection>
)
)}
</InfoSections>
</div>
)}
{createdDomain.txtRecords &&
createdDomain.txtRecords.length >
0 && (
<div>
<h3 className="font-medium mb-3">
TXT Records
</h3>
<InfoSections cols={1}>
{createdDomain.txtRecords.map(
(
txtRecord,
index
) => (
<InfoSection
key={index}
>
<InfoSectionTitle>
Record{" "}
{index +
1}
</InfoSectionTitle>
<InfoSectionContent>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Type:
</span>
<span className="text-sm font-mono">
TXT
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Name:
</span>
<span className="text-sm font-mono">
{
txtRecord.baseDomain
}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Value:
</span>
<CopyToClipboard
text={
txtRecord.value
}
/>
</div>
</div>
</InfoSectionContent>
</InfoSection>
)
)}
</InfoSections>
</div>
)}
</>
)}
</div>
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle className="font-semibold">
Save These Records
</AlertTitle>
<AlertDescription>
Make sure to save these DNS records as you
will not see them again.
</AlertDescription>
</Alert>
<Alert variant="info">
<AlertTriangle className="h-4 w-4" />
<AlertTitle className="font-semibold">
DNS Propagation
</AlertTitle>
<AlertDescription>
DNS changes may take some time to propagate
across the internet. This can take anywhere
from a few minutes to 48 hours, depending on
your DNS provider and TTL settings.
</AlertDescription>
</Alert>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
{!createdDomain && (
<Button
type="submit"
form="create-domain-form"
loading={loading}
disabled={loading}
>
{t("domainCreate")}
</Button>
)}
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "@app/components/ui/data-table";
import { useTranslations } from "next-intl";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
onAdd?: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
}
export function DomainsDataTable<TData, TValue>({
columns,
data,
onAdd,
onRefresh,
isRefreshing
}: DataTableProps<TData, TValue>) {
const t = useTranslations();
return (
<DataTable
columns={columns}
data={data}
title={t("domains")}
searchPlaceholder={t("domainsSearch")}
searchColumn="baseDomain"
addButtonText={t("domainAdd")}
onAdd={onAdd}
onRefresh={onRefresh}
isRefreshing={isRefreshing}
/>
);
}

View File

@@ -0,0 +1,261 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { DomainsDataTable } from "./DomainsDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowUpDown } from "lucide-react";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Badge } from "@app/components/ui/badge";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import CreateDomainForm from "./CreateDomainForm";
import { useToast } from "@app/hooks/useToast";
import { useOrgContext } from "@app/hooks/useOrgContext";
export type DomainRow = {
domainId: string;
baseDomain: string;
type: string;
verified: boolean;
failed: boolean;
tries: number;
};
type Props = {
domains: DomainRow[];
};
export default function DomainsTable({ domains }: Props) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [selectedDomain, setSelectedDomain] = useState<DomainRow | null>(
null
);
const [isRefreshing, setIsRefreshing] = useState(false);
const [restartingDomains, setRestartingDomains] = useState<Set<string>>(new Set());
const api = createApiClient(useEnvContext());
const router = useRouter();
const t = useTranslations();
const { toast } = useToast();
const { org } = useOrgContext();
const refreshData = async () => {
setIsRefreshing(true);
try {
router.refresh();
} catch (error) {
toast({
title: t("error"),
description: t("refreshError"),
variant: "destructive"
});
} finally {
setIsRefreshing(false);
}
};
const deleteDomain = async (domainId: string) => {
try {
await api.delete(`/org/${org.org.orgId}/domain/${domainId}`);
toast({
title: t("success"),
description: t("domainDeletedDescription")
});
setIsDeleteModalOpen(false);
refreshData();
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
}
};
const restartDomain = async (domainId: string) => {
setRestartingDomains(prev => new Set(prev).add(domainId));
try {
await api.post(`/org/${org.org.orgId}/domain/${domainId}/restart`);
toast({
title: t("success"),
description: t("domainRestartedDescription", { fallback: "Domain verification restarted successfully" })
});
refreshData();
} catch (e) {
toast({
title: t("error"),
description: formatAxiosError(e),
variant: "destructive"
});
} finally {
setRestartingDomains(prev => {
const newSet = new Set(prev);
newSet.delete(domainId);
return newSet;
});
}
};
const getTypeDisplay = (type: string) => {
switch (type) {
case "ns":
return t("selectDomainTypeNsName");
case "cname":
return t("selectDomainTypeCnameName");
default:
return type;
}
};
const columns: ColumnDef<DomainRow>[] = [
{
accessorKey: "baseDomain",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("domain")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "type",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("type")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const type = row.original.type;
return (
<Badge variant="secondary">{getTypeDisplay(type)}</Badge>
);
}
},
{
accessorKey: "verified",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("status")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const { verified, failed } = row.original;
if (verified) {
return <Badge variant="green">{t("verified")}</Badge>;
} else if (failed) {
return <Badge variant="destructive">{t("failed", { fallback: "Failed" })}</Badge>;
} else {
return <Badge variant="yellow">{t("pending")}</Badge>;
}
}
},
{
id: "actions",
cell: ({ row }) => {
const domain = row.original;
const isRestarting = restartingDomains.has(domain.domainId);
return (
<div className="flex items-center justify-end gap-2">
{domain.failed && (
<Button
variant="outline"
size="sm"
onClick={() => restartDomain(domain.domainId)}
disabled={isRestarting}
>
{isRestarting ? t("restarting", { fallback: "Restarting..." }) : t("restart", { fallback: "Restart" })}
</Button>
)}
<Button
variant="secondary"
size="sm"
onClick={() => {
setSelectedDomain(domain);
setIsDeleteModalOpen(true);
}}
>
{t("delete")}
</Button>
</div>
);
}
}
];
return (
<>
{selectedDomain && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedDomain(null);
}}
dialog={
<div className="space-y-4">
<p>
{t("domainQuestionRemove", {
domain: selectedDomain.baseDomain
})}
</p>
<p>
<b>{t("domainMessageRemove")}</b>
</p>
<p>{t("domainMessageConfirm")}</p>
</div>
}
buttonText={t("domainConfirmDelete")}
onConfirm={async () =>
deleteDomain(selectedDomain.domainId)
}
string={selectedDomain.baseDomain}
title={t("domainDelete")}
/>
)}
<CreateDomainForm
open={isCreateModalOpen}
setOpen={setIsCreateModalOpen}
onCreated={(domain) => {
refreshData();
}}
/>
<DomainsDataTable
columns={columns}
data={domains}
onAdd={() => setIsCreateModalOpen(true)}
onRefresh={refreshData}
isRefreshing={isRefreshing}
/>
</>
);
}

View File

@@ -0,0 +1,60 @@
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { AxiosResponse } from "axios";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import DomainsTable, { DomainRow } from "./DomainsTable";
import { getTranslations } from "next-intl/server";
import { cache } from "react";
import { GetOrgResponse } from "@server/routers/org";
import { redirect } from "next/navigation";
import OrgProvider from "@app/providers/OrgProvider";
import { ListDomainsResponse } from "@server/routers/domain";
type Props = {
params: Promise<{ orgId: string }>;
};
export default async function DomainsPage(props: Props) {
const params = await props.params;
let domains: DomainRow[] = [];
try {
const res = await internal.get<
AxiosResponse<ListDomainsResponse>
>(`/org/${params.orgId}/domains`, await authCookieHeader());
domains = res.data.data.domains as DomainRow[];
} catch (e) {
console.error(e);
}
let org = null;
try {
const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
await authCookieHeader()
)
);
const res = await getOrg();
org = res.data.data;
} catch {
redirect(`/${params.orgId}`);
}
if (!org) {
}
const t = await getTranslations();
return (
<>
<OrgProvider org={org}>
<SettingsSectionTitle
title={t("domains")}
description={t("domainsDescription")}
/>
<DomainsTable domains={domains} />
</OrgProvider>
</>
);
}

View File

@@ -202,25 +202,25 @@ export default function GeneralPage() {
)}
/>
<FormField
control={form.control}
name="subnet"
render={({ field }) => (
<FormItem>
<FormLabel>Subnet</FormLabel>
<FormControl>
<Input
{...field}
disabled={true}
/>
</FormControl>
<FormMessage />
<FormDescription>
The subnet for this organization's network configuration.
</FormDescription>
</FormItem>
)}
/>
{/* <FormField */}
{/* control={form.control} */}
{/* name="subnet" */}
{/* render={({ field }) => ( */}
{/* <FormItem> */}
{/* <FormLabel>Subnet</FormLabel> */}
{/* <FormControl> */}
{/* <Input */}
{/* {...field} */}
{/* disabled={true} */}
{/* /> */}
{/* </FormControl> */}
{/* <FormMessage /> */}
{/* <FormDescription> */}
{/* The subnet for this organization's network configuration. */}
{/* </FormDescription> */}
{/* </FormItem> */}
{/* )} */}
{/* /> */}
</form>
</Form>
</SettingsSectionForm>

View File

@@ -1,6 +1,7 @@
import { Metadata } from "next";
import {
Combine,
KeyRound,
LinkIcon,
Settings,
Users,
@@ -11,6 +12,7 @@ import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { internal } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { ListOrgsResponse } from "@server/routers/org";
import { GetOrgResponse, ListUserOrgsResponse } from "@server/routers/org";
import { authCookieHeader } from "@app/lib/api/cookies";
import { cache } from "react";

View File

@@ -32,6 +32,8 @@ import { Switch } from "@app/components/ui/switch";
import { AxiosResponse } from "axios";
import { UpdateResourceResponse } from "@server/routers/resource";
import { useTranslations } from "next-intl";
import { InfoPopup } from "@app/components/ui/info-popup";
import { Badge } from "@app/components/ui/badge";
export type ResourceRow = {
id: number;
@@ -45,6 +47,7 @@ export type ResourceRow = {
protocol: string;
proxyPort: number | null;
enabled: boolean;
domainId?: string;
};
type ResourcesTableProps = {
@@ -158,17 +161,26 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div>
{!resourceRow.http ? (
<CopyToClipboard
text={resourceRow.proxyPort!.toString()}
isLink={false}
<div className="flex items-center space-x-2">
{!resourceRow.domainId ? (
<InfoPopup
info={t("domainNotFoundDescription")}
text={t("domainNotFound")}
/>
) : (
<CopyToClipboard
text={resourceRow.domain}
isLink={true}
/>
<div>
{!resourceRow.http ? (
<CopyToClipboard
text={resourceRow.proxyPort!.toString()}
isLink={false}
/>
) : (
<CopyToClipboard
text={resourceRow.domain}
isLink={true}
/>
)}
</div>
)}
</div>
);
@@ -215,7 +227,10 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
header: t("enabled"),
cell: ({ row }) => (
<Switch
defaultChecked={row.original.enabled}
defaultChecked={
!row.original.domainId ? false : row.original.enabled
}
disabled={!row.original.domainId}
onCheckedChange={(val) =>
toggleResourceEnabled(val, row.original.id)
}
@@ -261,7 +276,11 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
<Link
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
>
<Button variant={"secondary"} className="ml-2" size="sm">
<Button
variant={"secondary"}
className="ml-2"
size="sm"
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>

View File

@@ -10,10 +10,14 @@ import {
InfoSections,
InfoSectionTitle
} from "@app/components/InfoSection";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useDockerSocket } from "@app/hooks/useDockerSocket";
import { useTranslations } from "next-intl";
import { AxiosResponse } from "axios";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { RotateCw } from "lucide-react";
import { createApiClient } from "@app/lib/api";
type ResourceInfoBoxType = {};

View File

@@ -205,10 +205,10 @@ export default function ResourceAuthenticationPage() {
console.error(e);
toast({
variant: "destructive",
title: t('resourceErrorAuthFetch'),
title: t("resourceErrorAuthFetch"),
description: formatAxiosError(
e,
t('resourceErrorAuthFetchDescription')
t("resourceErrorAuthFetchDescription")
)
});
}
@@ -235,18 +235,18 @@ export default function ResourceAuthenticationPage() {
});
toast({
title: t('resourceWhitelistSave'),
description: t('resourceWhitelistSaveDescription')
title: t("resourceWhitelistSave"),
description: t("resourceWhitelistSaveDescription")
});
router.refresh();
} catch (e) {
console.error(e);
toast({
variant: "destructive",
title: t('resourceErrorWhitelistSave'),
title: t("resourceErrorWhitelistSave"),
description: formatAxiosError(
e,
t('resourceErrorWhitelistSaveDescription')
t("resourceErrorWhitelistSaveDescription")
)
});
} finally {
@@ -283,18 +283,18 @@ export default function ResourceAuthenticationPage() {
});
toast({
title: t('resourceAuthSettingsSave'),
description: t('resourceAuthSettingsSaveDescription')
title: t("resourceAuthSettingsSave"),
description: t("resourceAuthSettingsSaveDescription")
});
router.refresh();
} catch (e) {
console.error(e);
toast({
variant: "destructive",
title: t('resourceErrorUsersRolesSave'),
title: t("resourceErrorUsersRolesSave"),
description: formatAxiosError(
e,
t('resourceErrorUsersRolesSaveDescription')
t("resourceErrorUsersRolesSaveDescription")
)
});
} finally {
@@ -310,8 +310,8 @@ export default function ResourceAuthenticationPage() {
})
.then(() => {
toast({
title: t('resourcePasswordRemove'),
description: t('resourcePasswordRemoveDescription')
title: t("resourcePasswordRemove"),
description: t("resourcePasswordRemoveDescription")
});
updateAuthInfo({
@@ -322,10 +322,10 @@ export default function ResourceAuthenticationPage() {
.catch((e) => {
toast({
variant: "destructive",
title: t('resourceErrorPasswordRemove'),
title: t("resourceErrorPasswordRemove"),
description: formatAxiosError(
e,
t('resourceErrorPasswordRemoveDescription')
t("resourceErrorPasswordRemoveDescription")
)
});
})
@@ -340,8 +340,8 @@ export default function ResourceAuthenticationPage() {
})
.then(() => {
toast({
title: t('resourcePincodeRemove'),
description: t('resourcePincodeRemoveDescription')
title: t("resourcePincodeRemove"),
description: t("resourcePincodeRemoveDescription")
});
updateAuthInfo({
@@ -352,10 +352,10 @@ export default function ResourceAuthenticationPage() {
.catch((e) => {
toast({
variant: "destructive",
title: t('resourceErrorPincodeRemove'),
title: t("resourceErrorPincodeRemove"),
description: formatAxiosError(
e,
t('resourceErrorPincodeRemoveDescription')
t("resourceErrorPincodeRemoveDescription")
)
});
})
@@ -400,140 +400,151 @@ export default function ResourceAuthenticationPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('resourceUsersRoles')}
{t("resourceUsersRoles")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('resourceUsersRolesDescription')}
{t("resourceUsersRolesDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SwitchInput
id="sso-toggle"
label={t('ssoUse')}
description={t('ssoUseDescription')}
defaultChecked={resource.sso}
onCheckedChange={(val) => setSsoEnabled(val)}
/>
<SettingsSectionForm>
<SwitchInput
id="sso-toggle"
label={t("ssoUse")}
defaultChecked={resource.sso}
onCheckedChange={(val) => setSsoEnabled(val)}
/>
<Form {...usersRolesForm}>
<form
onSubmit={usersRolesForm.handleSubmit(
onSubmitUsersRoles
)}
id="users-roles-form"
className="space-y-4"
>
{ssoEnabled && (
<>
<FormField
control={usersRolesForm.control}
name="roles"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t('roles')}</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeRolesTagIndex
}
setActiveTagIndex={
setActiveRolesTagIndex
}
placeholder={t('accessRoleSelect2')}
size="sm"
tags={
usersRolesForm.getValues()
.roles
}
setTags={(
newRoles
) => {
usersRolesForm.setValue(
"roles",
newRoles as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
allRoles
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t('resourceRoleDescription')}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={usersRolesForm.control}
name="users"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t('users')}</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeUsersTagIndex
}
setActiveTagIndex={
setActiveUsersTagIndex
}
placeholder={t('accessUserSelect')}
tags={
usersRolesForm.getValues()
.users
}
size="sm"
setTags={(
newUsers
) => {
usersRolesForm.setValue(
"users",
newUsers as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
allUsers
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</form>
</Form>
<Form {...usersRolesForm}>
<form
onSubmit={usersRolesForm.handleSubmit(
onSubmitUsersRoles
)}
id="users-roles-form"
className="space-y-4"
>
{ssoEnabled && (
<>
<FormField
control={usersRolesForm.control}
name="roles"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>
{t("roles")}
</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeRolesTagIndex
}
setActiveTagIndex={
setActiveRolesTagIndex
}
placeholder={t(
"accessRoleSelect2"
)}
size="sm"
tags={
usersRolesForm.getValues()
.roles
}
setTags={(
newRoles
) => {
usersRolesForm.setValue(
"roles",
newRoles as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
allRoles
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourceRoleDescription"
)}
</FormDescription>
</FormItem>
)}
/>
<FormField
control={usersRolesForm.control}
name="users"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>
{t("users")}
</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeUsersTagIndex
}
setActiveTagIndex={
setActiveUsersTagIndex
}
placeholder={t(
"accessUserSelect"
)}
tags={
usersRolesForm.getValues()
.users
}
size="sm"
setTags={(
newUsers
) => {
usersRolesForm.setValue(
"users",
newUsers as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={
true
}
autocompleteOptions={
allUsers
}
allowDuplicates={
false
}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
@@ -542,7 +553,7 @@ export default function ResourceAuthenticationPage() {
disabled={loadingSaveUsersRoles}
form="users-roles-form"
>
{t('resourceUsersRolesSubmit')}
{t("resourceUsersRolesSubmit")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
@@ -550,170 +561,195 @@ export default function ResourceAuthenticationPage() {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('resourceAuthMethods')}
{t("resourceAuthMethods")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('resourceAuthMethodsDescriptions')}
{t("resourceAuthMethodsDescriptions")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{/* Password Protection */}
<div className="flex items-center justify-between">
<div
className={`flex items-center text-${!authInfo.password ? "neutral" : "green"}-500 space-x-2`}
>
<Key />
<span>
{t('resourcePasswordProtection', {status: authInfo.password? t('enabled') : t('disabled')})}
</span>
<SettingsSectionForm>
{/* Password Protection */}
<div className="flex items-center justify-between border rounded-md p-2 mb-4">
<div
className={`flex items-center ${!authInfo.password ? "text-muted-foreground" : "text-green-500"} text-sm space-x-2`}
>
<Key size="14" />
<span>
{t("resourcePasswordProtection", {
status: authInfo.password
? t("enabled")
: t("disabled")
})}
</span>
</div>
<Button
variant="secondary"
size="sm"
onClick={
authInfo.password
? removeResourcePassword
: () => setIsSetPasswordOpen(true)
}
loading={loadingRemoveResourcePassword}
>
{authInfo.password
? t("passwordRemove")
: t("passwordAdd")}
</Button>
</div>
<Button
variant="secondary"
onClick={
authInfo.password
? removeResourcePassword
: () => setIsSetPasswordOpen(true)
}
loading={loadingRemoveResourcePassword}
>
{authInfo.password
? t('passwordRemove')
: t('passwordAdd')}
</Button>
</div>
{/* PIN Code Protection */}
<div className="flex items-center justify-between">
<div
className={`flex items-center text-${!authInfo.pincode ? "neutral" : "green"}-500 space-x-2`}
>
<Binary />
<span>
{t('resourcePincodeProtection', {status: authInfo.pincode ? t('enabled') : t('disabled')})}
</span>
{/* PIN Code Protection */}
<div className="flex items-center justify-between border rounded-md p-2">
<div
className={`flex items-center ${!authInfo.pincode ? "text-muted-foreground" : "text-green-500"} space-x-2 text-sm`}
>
<Binary size="14" />
<span>
{t("resourcePincodeProtection", {
status: authInfo.pincode
? t("enabled")
: t("disabled")
})}
</span>
</div>
<Button
variant="secondary"
size="sm"
onClick={
authInfo.pincode
? removeResourcePincode
: () => setIsSetPincodeOpen(true)
}
loading={loadingRemoveResourcePincode}
>
{authInfo.pincode
? t("pincodeRemove")
: t("pincodeAdd")}
</Button>
</div>
<Button
variant="secondary"
onClick={
authInfo.pincode
? removeResourcePincode
: () => setIsSetPincodeOpen(true)
}
loading={loadingRemoveResourcePincode}
>
{authInfo.pincode
? t('pincodeRemove')
: t('pincodeAdd')}
</Button>
</div>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t('otpEmailTitle')}
{t("otpEmailTitle")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t('otpEmailTitleDescription')}
{t("otpEmailTitleDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{!env.email.emailEnabled && (
<Alert variant="neutral" className="mb-4">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t('otpEmailSmtpRequired')}
</AlertTitle>
<AlertDescription>
{t('otpEmailSmtpRequiredDescription')}
</AlertDescription>
</Alert>
)}
<SwitchInput
id="whitelist-toggle"
label={t('otpEmailWhitelist')}
defaultChecked={resource.emailWhitelistEnabled}
onCheckedChange={setWhitelistEnabled}
disabled={!env.email.emailEnabled}
/>
<SettingsSectionForm>
{!env.email.emailEnabled && (
<Alert variant="neutral" className="mb-4">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
{t("otpEmailSmtpRequired")}
</AlertTitle>
<AlertDescription>
{t("otpEmailSmtpRequiredDescription")}
</AlertDescription>
</Alert>
)}
<SwitchInput
id="whitelist-toggle"
label={t("otpEmailWhitelist")}
defaultChecked={resource.emailWhitelistEnabled}
onCheckedChange={setWhitelistEnabled}
disabled={!env.email.emailEnabled}
/>
{whitelistEnabled && env.email.emailEnabled && (
<Form {...whitelistForm}>
<form id="whitelist-form">
<FormField
control={whitelistForm.control}
name="emails"
render={({ field }) => (
<FormItem>
<FormLabel>
<InfoPopup
text={t('otpEmailWhitelistList')}
info={t('otpEmailWhitelistListDescription')}
/>
</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: t('otpEmailErrorInvalid')
}
)
)
.safeParse(
tag
).success;
}}
setActiveTagIndex={
setActiveEmailTagIndex
}
placeholder={t('otpEmailEnter')}
tags={
whitelistForm.getValues()
.emails
}
setTags={(
newRoles
) => {
whitelistForm.setValue(
"emails",
newRoles as [
Tag,
...Tag[]
]
);
}}
allowDuplicates={
false
}
sortTags={true}
/>
</FormControl>
<FormDescription>
{t('otpEmailEnterDescription')}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
)}
{whitelistEnabled && env.email.emailEnabled && (
<Form {...whitelistForm}>
<form id="whitelist-form">
<FormField
control={whitelistForm.control}
name="emails"
render={({ field }) => (
<FormItem>
<FormLabel>
<InfoPopup
text={t(
"otpEmailWhitelistList"
)}
info={t(
"otpEmailWhitelistListDescription"
)}
/>
</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:
t(
"otpEmailErrorInvalid"
)
}
)
)
.safeParse(
tag
).success;
}}
setActiveTagIndex={
setActiveEmailTagIndex
}
placeholder={t(
"otpEmailEnter"
)}
tags={
whitelistForm.getValues()
.emails
}
setTags={(
newRoles
) => {
whitelistForm.setValue(
"emails",
newRoles as [
Tag,
...Tag[]
]
);
}}
allowDuplicates={
false
}
sortTags={true}
/>
</FormControl>
<FormDescription>
{t(
"otpEmailEnterDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
)}
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
@@ -722,7 +758,7 @@ export default function ResourceAuthenticationPage() {
loading={loadingSaveWhitelist}
disabled={loadingSaveWhitelist}
>
{t('otpEmailWhitelistSave')}
{t("otpEmailWhitelistSave")}
</Button>
</SettingsSectionFooter>
</SettingsSection>

View File

@@ -66,6 +66,18 @@ import {
} from "@server/routers/resource";
import { SwitchInput } from "@app/components/SwitchInput";
import { useTranslations } from "next-intl";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import DomainPicker from "@app/components/DomainPicker";
import { Globe } from "lucide-react";
const TransferFormSchema = z.object({
siteId: z.number()
@@ -80,6 +92,7 @@ export default function GeneralForm() {
const { org } = useOrgContext();
const router = useRouter();
const t = useTranslations();
const [editDomainOpen, setEditDomainOpen] = useState(false);
const { env } = useEnvContext();
@@ -99,46 +112,22 @@ export default function GeneralForm() {
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
resource.isBaseDomain ? "basedomain" : "subdomain"
);
const [resourceFullDomain, setResourceFullDomain] = useState(
`${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
);
const [selectedDomain, setSelectedDomain] = useState<{
domainId: string;
subdomain?: string;
fullDomain: string;
} | null>(null);
const GeneralFormSchema = z
.object({
enabled: z.boolean(),
subdomain: z.string().optional(),
name: z.string().min(1).max(255),
proxyPort: z.number().optional(),
http: z.boolean(),
isBaseDomain: z.boolean().optional(),
domainId: z.string().optional()
})
.refine(
(data) => {
if (!data.http) {
return z
.number()
.int()
.min(1)
.max(65535)
.safeParse(data.proxyPort).success;
}
return true;
},
{
message: t("proxyErrorInvalidPort"),
path: ["proxyPort"]
}
)
.refine(
(data) => {
if (data.http && !data.isBaseDomain) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
},
{
message: t("subdomainErrorInvalid"),
path: ["subdomain"]
}
);
});
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@@ -148,9 +137,6 @@ export default function GeneralForm() {
enabled: resource.enabled,
name: resource.name,
subdomain: resource.subdomain ? resource.subdomain : undefined,
proxyPort: resource.proxyPort ? resource.proxyPort : undefined,
http: resource.http,
isBaseDomain: resource.isBaseDomain ? true : false,
domainId: resource.domainId || undefined
},
mode: "onChange"
@@ -213,10 +199,8 @@ export default function GeneralForm() {
{
enabled: data.enabled,
name: data.name,
subdomain: data.http ? data.subdomain : undefined,
proxyPort: data.proxyPort,
isBaseDomain: data.http ? data.isBaseDomain : undefined,
domainId: data.http ? data.domainId : undefined
subdomain: data.subdomain,
domainId: data.domainId,
}
)
.catch((e) => {
@@ -242,8 +226,6 @@ export default function GeneralForm() {
enabled: data.enabled,
name: data.name,
subdomain: data.subdomain,
proxyPort: data.proxyPort,
isBaseDomain: data.isBaseDomain,
fullDomain: resource.fullDomain
});
@@ -288,449 +270,290 @@ export default function GeneralForm() {
return (
!loadingPage && (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceGeneral")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceGeneralDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<>
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceGeneral")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceGeneralDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form} key={formKey}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid grid-cols-1 md:grid-cols-2 gap-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="col-span-2">
<div className="flex items-center space-x-2">
<FormControl>
<SwitchInput
id="enable-resource"
defaultChecked={resource.enabled}
onCheckedChange={(val) => form.setValue("enabled", val)}
/>
</FormControl>
<div className="space-y-1">
<FormLabel className="text-base">
{t("resourceEnable")}
</FormLabel>
<FormDescription>
{t("resourceVisibilityTitleDescription")}
</FormDescription>
</div>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{resource.http && (
<>
{env.flags
.allowBaseDomainResources && (
<FormField
control={form.control}
name="isBaseDomain"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"domainType"
)}
</FormLabel>
<Select
value={
domainType
}
onValueChange={(
val
) => {
setDomainType(
val ===
"basedomain"
? "basedomain"
: "subdomain"
);
form.setValue(
"isBaseDomain",
val ===
"basedomain"
? true
: false
);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="subdomain">
{t(
"subdomain"
)}
</SelectItem>
<SelectItem value="basedomain">
{t(
"baseDomain"
)}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="col-span-2">
{domainType === "subdomain" ? (
<div className="w-fill space-y-2">
<FormLabel>
{t("subdomain")}
</FormLabel>
<div className="flex">
<div className="w-1/2">
<FormField
control={
form.control
}
name="subdomain"
render={({
field
}) => (
<FormItem>
<FormControl>
<Input
{...field}
className="border-r-0 rounded-r-none"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="w-1/2">
<FormField
control={
form.control
}
name="domainId"
render={({
field
}) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
value={
field.value
}
>
<FormControl>
<SelectTrigger className="rounded-l-none">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
.
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
) : (
<FormField
control={form.control}
name="domainId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"baseDomain"
)}
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value ||
baseDomains[0]
?.domainId
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</>
)}
{!resource.http && (
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form} key={formKey}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="proxyPort"
name="enabled"
render={({ field }) => (
<FormItem className="col-span-2">
<div className="flex items-center space-x-2">
<FormControl>
<SwitchInput
id="enable-resource"
defaultChecked={
resource.enabled
}
label={
t(
"resourceEnable"
)
}
onCheckedChange={(
val
) =>
form.setValue(
"enabled",
val
)
}
/>
</FormControl>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"resourcePortNumber"
)}
{t("name")}
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(e) =>
field.onChange(
e.target
.value
? parseInt(
e
.target
.value
)
: null
)
}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
form="general-settings-form"
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceTransfer")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceTransferDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...transferForm}>
<form
onSubmit={transferForm.handleSubmit(
onTransfer
)}
className="space-y-4"
id="transfer-form"
>
<FormField
control={transferForm.control}
name="siteId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("siteDestination")}
</FormLabel>
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(
site
) =>
site.siteId ===
field.value
)?.name
: t(
"siteSelect"
)}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput
placeholder={t(
"searchSites"
)}
/>
<CommandEmpty>
{t(
"sitesNotFound"
)}
</CommandEmpty>
<CommandGroup>
{sites.map(
(site) => (
<CommandItem
value={`${site.name}:${site.siteId}`}
key={
site.siteId
}
onSelect={() => {
transferForm.setValue(
"siteId",
site.siteId
);
setOpen(
false
);
}}
>
{
site.name
}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
)
)}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
{resource.http && (
<div className="space-y-2">
<Label>Domain</Label>
<div className="border p-2 rounded-md flex items-center justify-between">
<span className="text-sm text-muted-foreground flex items-center gap-2">
<Globe size="14"/>
{resourceFullDomain}
</span>
<Button
variant="secondary"
type="button"
size="sm"
onClick={() =>
setEditDomainOpen(
true
)
}
>
Edit Domain
</Button>
</div>
</div>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={transferLoading}
disabled={transferLoading}
form="transfer-form"
>
{t("resourceTransferSubmit")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
<SettingsSectionFooter>
<Button
type="submit"
onClick={() => {
console.log(form.getValues());
}}
loading={saveLoading}
disabled={saveLoading}
form="general-settings-form"
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceTransfer")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceTransferDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...transferForm}>
<form
onSubmit={transferForm.handleSubmit(
onTransfer
)}
className="space-y-4"
id="transfer-form"
>
<FormField
control={transferForm.control}
name="siteId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("siteDestination")}
</FormLabel>
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(
site
) =>
site.siteId ===
field.value
)
?.name
: t(
"siteSelect"
)}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput
placeholder={t(
"searchSites"
)}
/>
<CommandEmpty>
{t(
"sitesNotFound"
)}
</CommandEmpty>
<CommandGroup>
{sites.map(
(
site
) => (
<CommandItem
value={`${site.name}:${site.siteId}`}
key={
site.siteId
}
onSelect={() => {
transferForm.setValue(
"siteId",
site.siteId
);
setOpen(
false
);
}}
>
{
site.name
}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
)
)}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={transferLoading}
disabled={transferLoading}
form="transfer-form"
>
{t("resourceTransferSubmit")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
<Credenza
open={editDomainOpen}
onOpenChange={(setOpen) => setEditDomainOpen(setOpen)}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Edit Domain</CredenzaTitle>
<CredenzaDescription>
Select a domain for your resource
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<DomainPicker
orgId={orgId as string}
onDomainChange={(res) => {
const selected = {
domainId: res.domainId,
subdomain: res.subdomain,
fullDomain: res.fullDomain
};
setSelectedDomain(selected);
}}
/>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("cancel")}</Button>
</CredenzaClose>
<Button onClick={() => {
if (selectedDomain) {
setResourceFullDomain(selectedDomain.fullDomain);
form.setValue("domainId", selectedDomain.domainId);
form.setValue("subdomain", selectedDomain.subdomain);
setEditDomainOpen(false);
}
}}>Select Domain</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
</>
)
);
}

View File

@@ -772,81 +772,50 @@ export default function ReverseProxyTargets(props: {
className="space-y-4"
id="tls-settings-form"
>
<FormField
control={tlsSettingsForm.control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="ssl-toggle"
label={t(
"proxyEnableSSL"
)}
defaultChecked={
field.value
}
onCheckedChange={(
val
) => {
field.onChange(val);
}}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={tlsSettingsForm.control}
name="ssl"
name="tlsServerName"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("targetTlsSni")}
</FormLabel>
<FormControl>
<SwitchInput
id="ssl-toggle"
label={t(
"proxyEnableSSL"
)}
defaultChecked={
field.value
}
onCheckedChange={(
val
) => {
field.onChange(val);
}}
/>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"targetTlsSniDescription"
)}
</FormDescription>
<FormMessage />
</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"
>
<p className="text-sm text-muted-foreground">
{t(
"targetTlsSettingsAdvanced"
)}
</p>
<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>
{t("targetTlsSni")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t(
"targetTlsSniDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
</form>
</Form>
</SettingsSectionForm>

View File

@@ -247,7 +247,7 @@ export default function ResourceRules(props: {
async function saveAllSettings() {
try {
setLoading(true);
// Save rules enabled state
const res = await api
.post(`/resource/${params.resourceId}`, {
@@ -594,17 +594,10 @@ export default function ResourceRules(props: {
<div className="flex items-center space-x-2">
<SwitchInput
id="rules-toggle"
label={t('rulesEnable')}
defaultChecked={rulesEnabled}
onCheckedChange={(val) => setRulesEnabled(val)}
/>
<div className="space-y-1">
<label className="text-base font-medium">
{t('rulesEnable')}
</label>
<p className="text-sm text-muted-foreground">
{t('rulesEnableDescription')}
</p>
</div>
</div>
<Form {...addRuleForm}>

View File

@@ -63,6 +63,7 @@ import { SquareArrowOutUpRight } from "lucide-react";
import CopyTextBox from "@app/components/CopyTextBox";
import Link from "next/link";
import { useTranslations } from "next-intl";
import DomainPicker from "@app/components/DomainPicker";
const baseResourceFormSchema = z.object({
name: z.string().min(1).max(255),
@@ -70,17 +71,10 @@ const baseResourceFormSchema = z.object({
http: z.boolean()
});
const httpResourceFormSchema = z.discriminatedUnion("isBaseDomain", [
z.object({
isBaseDomain: z.literal(true),
domainId: z.string().min(1)
}),
z.object({
isBaseDomain: z.literal(false),
domainId: z.string().min(1),
subdomain: z.string().pipe(subdomainSchema)
})
]);
const httpResourceFormSchema = z.object({
domainId: z.string().optional(),
subdomain: z.string().optional()
});
const tcpUdpResourceFormSchema = z.object({
protocol: z.string(),
@@ -143,11 +137,7 @@ export default function Page() {
const httpForm = useForm<HttpResourceFormValues>({
resolver: zodResolver(httpResourceFormSchema),
defaultValues: {
subdomain: "",
domainId: "",
isBaseDomain: false
}
defaultValues: {}
});
const tcpUdpForm = useForm<TcpUdpResourceFormValues>({
@@ -173,20 +163,10 @@ export default function Page() {
if (isHttp) {
const httpData = httpForm.getValues();
if (httpData.isBaseDomain) {
Object.assign(payload, {
domainId: httpData.domainId,
isBaseDomain: true,
protocol: "tcp"
});
} else {
Object.assign(payload, {
subdomain: httpData.subdomain,
domainId: httpData.domainId,
isBaseDomain: false,
protocol: "tcp"
domainId: httpData.domainId
});
}
} else {
const tcpUdpData = tcpUdpForm.getValues();
Object.assign(payload, {
@@ -498,218 +478,23 @@ export default function Page() {
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...httpForm}>
<form
className="space-y-4"
id="http-settings-form"
>
{env.flags
.allowBaseDomainResources && (
<FormField
control={
httpForm.control
}
name="isBaseDomain"
render={({
field
}) => (
<FormItem>
<FormLabel>
{t(
"domainType"
)}
</FormLabel>
<Select
value={
field.value
? "basedomain"
: "subdomain"
}
onValueChange={(
value
) => {
field.onChange(
value ===
"basedomain"
);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="subdomain">
{t(
"subdomain"
)}
</SelectItem>
<SelectItem value="basedomain">
{t(
"baseDomain"
)}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
{!httpForm.watch(
"isBaseDomain"
) && (
<FormItem>
<FormLabel>
{t("subdomain")}
</FormLabel>
<div className="flex space-x-0">
<div className="w-1/2">
<FormField
control={
httpForm.control
}
name="subdomain"
render={({
field
}) => (
<FormItem>
<FormControl>
<Input
{...field}
className="border-r-0 rounded-r-none"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="w-1/2">
<FormField
control={
httpForm.control
}
name="domainId"
render={({
field
}) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
value={
field.value
}
defaultValue={
field.value
}
>
<FormControl>
<SelectTrigger className="rounded-l-none">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
.
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<FormDescription>
{t(
"subdomnainDescription"
)}
</FormDescription>
</FormItem>
)}
{httpForm.watch(
"isBaseDomain"
) && (
<FormField
control={
httpForm.control
}
name="domainId"
render={({
field
}) => (
<FormItem>
<FormLabel>
{t(
"baseDomain"
)}
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
{...field}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>
<DomainPicker
orgId={orgId as string}
onDomainChange={(res) => {
httpForm.setValue(
"subdomain",
res.subdomain
);
httpForm.setValue(
"domainId",
res.domainId
);
console.log(
"Domain changed:",
res
);
}}
/>
</SettingsSectionBody>
</SettingsSection>
) : (
@@ -921,7 +706,7 @@ export default function Page() {
type="button"
onClick={() =>
router.push(
`/${orgId}/settings/resources/${resourceId}`
`/${orgId}/settings/resources/${resourceId}/proxy`
)
}
>

View File

@@ -67,7 +67,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
resource.whitelist
? "protected"
: "not_protected",
enabled: resource.enabled
enabled: resource.enabled,
domainId: resource.domainId || undefined
};
});

View File

@@ -5,10 +5,12 @@ import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ArrowRight, DockIcon as Docker, Globe, Server, X } from "lucide-react";
import Link from "next/link";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from 'next-intl';
export const SitesSplashCard = () => {
const [isDismissed, setIsDismissed] = useState(true);
const { env } = useEnvContext();
const key = "sites-splash-card-dismissed";
const t = useTranslations();

View File

@@ -375,7 +375,10 @@ WantedBy=default.target`
async function onSubmit(data: CreateSiteFormValues) {
setCreateLoading(true);
let payload: CreateSiteBody = { name: data.name, type: data.method };
let payload: CreateSiteBody = {
name: data.name,
type: data.method as "newt" | "wireguard" | "local"
};
if (data.method == "wireguard") {
if (!siteDefaults || !wgConfig) {
@@ -412,7 +415,7 @@ WantedBy=default.target`
exitNodeId: siteDefaults.exitNodeId,
secret: siteDefaults.newtSecret,
newtId: siteDefaults.newtId,
address: clientAddress
// address: clientAddress
};
}
@@ -573,42 +576,42 @@ WantedBy=default.target`
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientAddress"
render={({ field }) => (
<FormItem>
<FormLabel>
Site Address
</FormLabel>
<FormControl>
<Input
autoComplete="off"
value={
clientAddress
}
onChange={(
e
) => {
setClientAddress(
e.target
.value
);
field.onChange(
e.target
.value
);
}}
/>
</FormControl>
<FormMessage />
<FormDescription>
Specify the IP
address of the host.
</FormDescription>
</FormItem>
)}
/>
{/* <FormField */}
{/* control={form.control} */}
{/* name="clientAddress" */}
{/* render={({ field }) => ( */}
{/* <FormItem> */}
{/* <FormLabel> */}
{/* Site Address */}
{/* </FormLabel> */}
{/* <FormControl> */}
{/* <Input */}
{/* autoComplete="off" */}
{/* value={ */}
{/* clientAddress */}
{/* } */}
{/* onChange={( */}
{/* e */}
{/* ) => { */}
{/* setClientAddress( */}
{/* e.target */}
{/* .value */}
{/* ); */}
{/* field.onChange( */}
{/* e.target */}
{/* .value */}
{/* ); */}
{/* }} */}
{/* /> */}
{/* </FormControl> */}
{/* <FormMessage /> */}
{/* <FormDescription> */}
{/* Specify the IP */}
{/* address of the host. */}
{/* </FormDescription> */}
{/* </FormItem> */}
{/* )} */}
{/* /> */}
</form>
</Form>
</SettingsSectionForm>
@@ -760,7 +763,7 @@ WantedBy=default.target`
? "squareOutlinePrimary"
: "squareOutline"
}
className={`flex-1 min-w-[120px] ${platform === os ? "bg-primary/10" : ""}`}
className={`flex-1 min-w-[120px] ${platform === os ? "bg-primary/10" : ""} shadow-none`}
onClick={() => {
setPlatform(os);
}}
@@ -791,7 +794,7 @@ WantedBy=default.target`
? "squareOutlinePrimary"
: "squareOutline"
}
className={`flex-1 min-w-[120px] ${architecture === arch ? "bg-primary/10" : ""}`}
className={`flex-1 min-w-[120px] ${architecture === arch ? "bg-primary/10" : ""} shadow-none`}
onClick={() =>
setArchitecture(
arch

View File

@@ -1,5 +1,6 @@
import { Metadata } from "next";
import { Users } from "lucide-react";
import { TopbarNav } from "@app/components/TopbarNav";
import { KeyRound, Users } from "lucide-react";
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { cache } from "react";

View File

@@ -1,4 +1,5 @@
import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { Separator } from "@app/components/ui/separator";
import { priv } from "@app/lib/api";
import { verifySession } from "@app/lib/auth/verifySession";
@@ -11,7 +12,7 @@ import { cache } from "react";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = {
title: `Auth - Pangolin`,
title: `Auth - "Pangolin`,
description: ""
};
@@ -23,6 +24,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
const getUser = cache(verifySession);
const user = await getUser();
const t = await getTranslations();
const hideFooter = true;
const licenseStatusRes = await cache(
async () =>
@@ -34,20 +36,18 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
return (
<div className="h-full flex flex-col">
{user && (
<UserProvider user={user}>
<div className="p-3 ml-auto">
<ProfileIcon />
</div>
</UserProvider>
)}
<div className="flex justify-end items-center p-3 space-x-2">
<ThemeSwitcher />
</div>
<div className="flex-1 flex items-center justify-center">
<div className="w-full max-w-md p-3">{children}</div>
</div>
{!(
licenseStatus.isHostLicensed && licenseStatus.isLicenseValid
hideFooter || (
licenseStatus.isHostLicensed &&
licenseStatus.isLicenseValid)
) && (
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600">
@@ -73,7 +73,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
aria-label="GitHub"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>{t('communityEdition')}</span>
<span>{t("communityEdition")}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"

View File

@@ -54,10 +54,10 @@ export default async function Page(props: {
<div className="flex flex-col items-center">
<Mail className="w-12 h-12 mb-4 text-primary" />
<h2 className="text-2xl font-bold mb-2 text-center">
{t('inviteAlready')}
{t("inviteAlready")}
</h2>
<p className="text-center">
{t('inviteAlreadyDescription')}
{t("inviteAlreadyDescription")}
</p>
</div>
</div>
@@ -67,7 +67,7 @@ export default async function Page(props: {
{(!signUpDisabled || isInvite) && (
<p className="text-center text-muted-foreground mt-4">
{t('authNoAccount')}{" "}
{t("authNoAccount")}{" "}
<Link
href={
!redirectUrl
@@ -76,7 +76,7 @@ export default async function Page(props: {
}
className="underline"
>
{t('signup')}
{t("signup")}
</Link>
</p>
)}

View File

@@ -54,12 +54,14 @@ export type ResetPasswordFormProps = {
emailParam?: string;
tokenParam?: string;
redirect?: string;
quickstart?: boolean;
};
export default function ResetPasswordForm({
emailParam,
tokenParam,
redirect
redirect,
quickstart
}: ResetPasswordFormProps) {
const router = useRouter();
@@ -184,17 +186,63 @@ export default function ResetPasswordForm({
return;
}
setSuccessMessage(t('passwordResetSuccess'));
setSuccessMessage(quickstart ? t('accountSetupSuccess') : t('passwordResetSuccess'));
setTimeout(() => {
if (redirect) {
const safe = cleanRedirect(redirect);
router.push(safe);
} else {
router.push("/login");
// Auto-login after successful password reset
try {
const loginRes = await api.post("/auth/login", {
email: form.getValues("email"),
password: form.getValues("password")
});
if (loginRes.data.data?.codeRequested) {
if (redirect) {
router.push(`/auth/login?redirect=${redirect}`);
} else {
router.push("/auth/login");
}
return;
}
setIsSubmitting(false);
}, 1500);
if (loginRes.data.data?.emailVerificationRequired) {
try {
await api.post("/auth/verify-email/request");
} catch (verificationError) {
console.error("Failed to send verification code:", verificationError);
}
if (redirect) {
router.push(`/auth/verify-email?redirect=${redirect}`);
} else {
router.push("/auth/verify-email");
}
return;
}
// Login successful, redirect
setTimeout(() => {
if (redirect) {
const safe = cleanRedirect(redirect);
router.push(safe);
} else {
router.push("/");
}
setIsSubmitting(false);
}, 1500);
} catch (loginError) {
// Auto-login failed, but password reset was successful
console.error("Auto-login failed:", loginError);
setTimeout(() => {
if (redirect) {
const safe = cleanRedirect(redirect);
router.push(safe);
} else {
router.push("/login");
}
setIsSubmitting(false);
}, 1500);
}
}
}
@@ -202,9 +250,14 @@ export default function ResetPasswordForm({
<div>
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t('passwordReset')}</CardTitle>
<CardTitle>
{quickstart ? t('completeAccountSetup') : t('passwordReset')}
</CardTitle>
<CardDescription>
{t('passwordResetDescription')}
{quickstart
? t('completeAccountSetupDescription')
: t('passwordResetDescription')
}
</CardDescription>
</CardHeader>
<CardContent>
@@ -229,7 +282,10 @@ export default function ResetPasswordForm({
</FormControl>
<FormMessage />
<FormDescription>
{t('passwordResetSent')}
{quickstart
? t('accountSetupSent')
: t('passwordResetSent')
}
</FormDescription>
</FormItem>
)}
@@ -269,7 +325,10 @@ export default function ResetPasswordForm({
render={({ field }) => (
<FormItem>
<FormLabel>
{t('passwordResetCode')}
{quickstart
? t('accountSetupCode')
: t('passwordResetCode')
}
</FormLabel>
<FormControl>
<Input
@@ -279,7 +338,10 @@ export default function ResetPasswordForm({
</FormControl>
<FormMessage />
<FormDescription>
{t('passwordResetCodeDescription')}
{quickstart
? t('accountSetupCodeDescription')
: t('passwordResetCodeDescription')
}
</FormDescription>
</FormItem>
)}
@@ -292,7 +354,10 @@ export default function ResetPasswordForm({
render={({ field }) => (
<FormItem>
<FormLabel>
{t('passwordNew')}
{quickstart
? t('passwordCreate')
: t('passwordNew')
}
</FormLabel>
<FormControl>
<Input
@@ -310,7 +375,10 @@ export default function ResetPasswordForm({
render={({ field }) => (
<FormItem>
<FormLabel>
{t('passwordNewConfirm')}
{quickstart
? t('passwordCreateConfirm')
: t('passwordNewConfirm')
}
</FormLabel>
<FormControl>
<Input
@@ -407,7 +475,7 @@ export default function ResetPasswordForm({
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{state === "reset"
? t('passwordReset')
? (quickstart ? t('completeSetup') : t('passwordReset'))
: t('pincodeSubmit2')}
</Button>
)}
@@ -422,7 +490,10 @@ export default function ResetPasswordForm({
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t('passwordResetSubmit')}
{quickstart
? t('accountSetupSubmit')
: t('passwordResetSubmit')
}
</Button>
)}

View File

@@ -13,6 +13,7 @@ export default async function Page(props: {
redirect: string | undefined;
email: string | undefined;
token: string | undefined;
quickstart?: string | undefined;
}>;
}) {
const searchParams = await props.searchParams;
@@ -35,6 +36,9 @@ export default async function Page(props: {
redirect={searchParams.redirect}
tokenParam={searchParams.token}
emailParam={searchParams.email}
quickstart={
searchParams.quickstart === "true" ? true : undefined
}
/>
<p className="text-center text-muted-foreground mt-4">
@@ -46,7 +50,7 @@ export default async function Page(props: {
}
className="underline"
>
{t('loginBack')}
{t("loginBack")}
</Link>
</p>
</>

View File

@@ -43,6 +43,7 @@ import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import Link from "next/link";
import Image from "next/image";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useTranslations } from "next-intl";
@@ -185,8 +186,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
setOtpState("otp_sent");
submitOtpForm.setValue("email", values.email);
toast({
title: t('otpEmailSent'),
description: t('otpEmailSentDescription')
title: t("otpEmailSent"),
description: t("otpEmailSentDescription")
});
return;
}
@@ -202,7 +203,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
.catch((e) => {
console.error(e);
setWhitelistError(
formatAxiosError(e, t('otpEmailErrorAuthenticate'))
formatAxiosError(e, t("otpEmailErrorAuthenticate"))
);
})
.then(() => setLoadingLogin(false));
@@ -227,7 +228,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
.catch((e) => {
console.error(e);
setPincodeError(
formatAxiosError(e, t('pincodeErrorAuthenticate'))
formatAxiosError(e, t("pincodeErrorAuthenticate"))
);
})
.then(() => setLoadingLogin(false));
@@ -255,7 +256,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
.catch((e) => {
console.error(e);
setPasswordError(
formatAxiosError(e, t('passwordErrorAuthenticate'))
formatAxiosError(e, t("passwordErrorAuthenticate"))
);
})
.finally(() => setLoadingLogin(false));
@@ -276,30 +277,25 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
}
}
function getTitle() {
return t("authenticationRequired");
}
function getSubtitle(resourceName: string) {
return numMethods > 1
? t("authenticationMethodChoose", { name: props.resource.name })
: t("authenticationRequest", { name: props.resource.name });
}
return (
<div>
{!accessDenied ? (
<div>
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
{t('poweredBy')}{" "}
<Link
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Pangolin
</Link>
</span>
</div>
<Card>
<CardHeader>
<CardTitle>{t('authenticationRequired')}</CardTitle>
<CardTitle>{getTitle()}</CardTitle>
<CardDescription>
{numMethods > 1
? t('authenticationMethodChoose', {name: props.resource.name})
: t('authenticationRequest', {name: props.resource.name})}
{getSubtitle(props.resource.name)}
</CardDescription>
</CardHeader>
<CardContent>
@@ -329,19 +325,19 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
{props.methods.password && (
<TabsTrigger value="password">
<Key className="w-4 h-4 mr-1" />{" "}
{t('password')}
{t("password")}
</TabsTrigger>
)}
{props.methods.sso && (
<TabsTrigger value="sso">
<User className="w-4 h-4 mr-1" />{" "}
{t('user')}
{t("user")}
</TabsTrigger>
)}
{props.methods.whitelist && (
<TabsTrigger value="whitelist">
<AtSign className="w-4 h-4 mr-1" />{" "}
{t('email')}
{t("email")}
</TabsTrigger>
)}
</TabsList>
@@ -364,7 +360,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('pincodeInput')}
{t(
"pincodeInput"
)}
</FormLabel>
<FormControl>
<div className="flex justify-center">
@@ -433,7 +431,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
disabled={loadingLogin}
>
<LockIcon className="w-4 h-4 mr-2" />
{t('pincodeSubmit')}
{t("pincodeSubmit")}
</Button>
</form>
</Form>
@@ -459,7 +457,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('password')}
{t("password")}
</FormLabel>
<FormControl>
<Input
@@ -487,7 +485,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
disabled={loadingLogin}
>
<LockIcon className="w-4 h-4 mr-2" />
{t('passwordSubmit')}
{t("passwordSubmit")}
</Button>
</form>
</Form>
@@ -528,7 +526,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('email')}
{t("email")}
</FormLabel>
<FormControl>
<Input
@@ -537,7 +535,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
/>
</FormControl>
<FormDescription>
{t('otpEmailDescription')}
{t(
"otpEmailDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
@@ -559,7 +559,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
disabled={loadingLogin}
>
<Send className="w-4 h-4 mr-2" />
{t('otpEmailSend')}
{t("otpEmailSend")}
</Button>
</form>
</Form>
@@ -581,7 +581,9 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
render={({ field }) => (
<FormItem>
<FormLabel>
{t('otpEmail')}
{t(
"otpEmail"
)}
</FormLabel>
<FormControl>
<Input
@@ -609,7 +611,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
disabled={loadingLogin}
>
<LockIcon className="w-4 h-4 mr-2" />
{t('otpEmailSubmit')}
{t("otpEmailSubmit")}
</Button>
<Button
@@ -621,7 +623,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
submitOtpForm.reset();
}}
>
{t('backToEmail')}
{t("backToEmail")}
</Button>
</form>
</Form>
@@ -634,7 +636,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
{supporterStatus?.visible && (
<div className="text-center mt-2">
<span className="text-sm text-muted-foreground opacity-50">
{t('noSupportKey')}
{t("noSupportKey")}
</span>
</div>
)}

View File

@@ -57,7 +57,9 @@ export default function SignupForm({
}: SignupFormProps) {
const router = useRouter();
const api = createApiClient(useEnvContext());
const { env } = useEnvContext();
const api = createApiClient({ env });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -116,8 +118,8 @@ export default function SignupForm({
}
return (
<Card className="w-full max-w-md">
<CardHeader>
<Card className="w-full max-w-md shadow-md">
<CardHeader className="border-b">
<div className="flex flex-row items-center justify-center">
<Image
src={`/logo/pangolin_orange.svg`}
@@ -135,7 +137,7 @@ export default function SignupForm({
</p>
</div>
</CardHeader>
<CardContent>
<CardContent className="pt-6">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
@@ -161,10 +163,7 @@ export default function SignupForm({
<FormItem>
<FormLabel>{t('password')}</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -177,10 +176,7 @@ export default function SignupForm({
<FormItem>
<FormLabel>{t('confirmPassword')}</FormLabel>
<FormControl>
<Input
type="password"
{...field}
/>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -111,6 +111,9 @@
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--shadow-2xs: 0 1px 1px rgba(0, 0, 0, 0.03);
--inset-shadow-2xs: inset 0 1px 1px rgba(0, 0, 1, 0.03);
}
@layer base {

View File

@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import "./globals.css";
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 { pullEnv } from "@app/lib/pullEnv";
@@ -15,10 +14,11 @@ import LicenseViolation from "@app/components/LicenseViolation";
import { cache } from "react";
import { NextIntlClientProvider } from "next-intl";
import { getLocale } from "next-intl/server";
import { Toaster } from "@app/components/ui/toaster";
export const metadata: Metadata = {
title: `Dashboard - Pangolin`,
description: ""
description: "",
};
export const dynamic = "force-dynamic";
@@ -83,4 +83,4 @@ export default async function RootLayout({
</body>
</html>
);
}
}

View File

@@ -10,7 +10,8 @@ import {
Workflow,
KeyRound,
TicketCheck,
User
User,
Globe
} from "lucide-react";
export type SidebarNavSection = {
@@ -31,6 +32,11 @@ export const orgNavSections: SidebarNavSection[] = [
title: "sidebarResources",
href: "/{orgId}/settings/resources",
icon: <Waypoints className="h-4 w-4" />
},
{
title: "sidebarDomains",
href: "/{orgId}/settings/domains",
icon: <Globe className="h-4 w-4" />
}
]
},

View File

@@ -75,35 +75,33 @@ export default async function Page(props: {
const allCookies = await cookies();
const lastOrgCookie = allCookies.get("pangolin-last-org")?.value;
if (lastOrgCookie && orgs.length > 0) {
const lastOrgExists = orgs.some((org) => org.orgId === lastOrgCookie);
if (lastOrgExists) {
redirect(`/${lastOrgCookie}`);
const lastOrgExists = orgs.some((org) => org.orgId === lastOrgCookie);
if (lastOrgExists) {
redirect(`/${lastOrgCookie}`);
} else {
const ownedOrg = orgs.find((org) => org.isOwner);
if (ownedOrg) {
redirect(`/${ownedOrg.orgId}`);
} else {
const ownedOrg = orgs.find((org) => org.isOwner);
if (ownedOrg) {
redirect(`/${ownedOrg.orgId}`);
} else {
redirect("/setup");
}
redirect("/setup");
}
}
return (
<UserProvider user={user}>
<Layout orgs={orgs} navItems={[]}>
<div className="w-full max-w-md mx-auto md:mt-32 mt-4">
<OrganizationLanding
disableCreateOrg={
env.flags.disableUserCreateOrg && !user.serverAdmin
}
organizations={orgs.map((org) => ({
name: org.name,
id: org.orgId
}))}
/>
</div>
</Layout>
</UserProvider>
);
// return (
// <UserProvider user={user}>
// <Layout orgs={orgs} navItems={[]}>
// <div className="w-full max-w-md mx-auto md:mt-32 mt-4">
// <OrganizationLanding
// disableCreateOrg={
// env.flags.disableUserCreateOrg && !user.serverAdmin
// }
// organizations={orgs.map((org) => ({
// name: org.name,
// id: org.orgId
// }))}
// />
// </div>
// </Layout>
// </UserProvider>
// );
}

View File

@@ -0,0 +1,499 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
AlertCircle,
CheckCircle2,
Building2,
Zap,
ArrowUpDown
} from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { createApiClient, formatAxiosError } from "@/lib/api";
import { useEnvContext } from "@/hooks/useEnvContext";
import { toast } from "@/hooks/useToast";
import { ListDomainsResponse } from "@server/routers/domain/listDomains";
import { AxiosResponse } from "axios";
import { cn } from "@/lib/cn";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTranslations } from "next-intl";
type OrganizationDomain = {
domainId: string;
baseDomain: string;
verified: boolean;
type: "ns" | "cname";
};
type AvailableOption = {
domainNamespaceId: string;
fullDomain: string;
domainId: string;
};
type DomainOption = {
id: string;
domain: string;
type: "organization" | "provided";
verified?: boolean;
domainType?: "ns" | "cname";
domainId?: string;
domainNamespaceId?: string;
subdomain?: string;
};
interface DomainPickerProps {
orgId: string;
onDomainChange?: (domainInfo: {
domainId: string;
domainNamespaceId?: string;
type: "organization" | "provided";
subdomain?: string;
fullDomain: string;
baseDomain: string;
}) => void;
}
export default function DomainPicker({
orgId,
onDomainChange
}: DomainPickerProps) {
const { env } = useEnvContext();
const api = createApiClient({ env });
const t = useTranslations();
const [userInput, setUserInput] = useState<string>("");
const [selectedOption, setSelectedOption] = useState<DomainOption | null>(
null
);
const [availableOptions, setAvailableOptions] = useState<AvailableOption[]>(
[]
);
const [isChecking, setIsChecking] = useState(false);
const [organizationDomains, setOrganizationDomains] = useState<
OrganizationDomain[]
>([]);
const [loadingDomains, setLoadingDomains] = useState(false);
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
const [activeTab, setActiveTab] = useState<
"all" | "organization" | "provided"
>("all");
const [providedDomainsShown, setProvidedDomainsShown] = useState(3);
useEffect(() => {
const loadOrganizationDomains = async () => {
setLoadingDomains(true);
try {
const response = await api.get<
AxiosResponse<ListDomainsResponse>
>(`/org/${orgId}/domains`);
if (response.status === 200) {
const domains = response.data.data.domains
.filter(
(domain) =>
domain.type === "ns" || domain.type === "cname"
)
.map((domain) => ({
...domain,
type: domain.type as "ns" | "cname"
}));
setOrganizationDomains(domains);
}
} catch (error) {
console.error("Failed to load organization domains:", error);
toast({
variant: "destructive",
title: "Error",
description: "Failed to load organization domains"
});
} finally {
setLoadingDomains(false);
}
};
loadOrganizationDomains();
}, [orgId, api]);
// Generate domain options based on user input
const generateDomainOptions = (): DomainOption[] => {
const options: DomainOption[] = [];
if (!userInput.trim()) return options;
// Check if input is more than one level deep (contains multiple dots)
const isMultiLevel = (userInput.match(/\./g) || []).length > 1;
// Add organization domain options
organizationDomains.forEach((orgDomain) => {
if (orgDomain.type === "cname") {
// For CNAME domains, check if the user input matches exactly
if (
orgDomain.baseDomain.toLowerCase() ===
userInput.toLowerCase()
) {
options.push({
id: `org-${orgDomain.domainId}`,
domain: orgDomain.baseDomain,
type: "organization",
verified: orgDomain.verified,
domainType: "cname",
domainId: orgDomain.domainId
});
}
} else if (orgDomain.type === "ns") {
// For NS domains, check if the user input could be a subdomain
const userInputLower = userInput.toLowerCase();
const baseDomainLower = orgDomain.baseDomain.toLowerCase();
// Check if user input ends with the base domain
if (userInputLower.endsWith(`.${baseDomainLower}`)) {
const subdomain = userInputLower.slice(
0,
-(baseDomainLower.length + 1)
);
options.push({
id: `org-${orgDomain.domainId}`,
domain: userInput,
type: "organization",
verified: orgDomain.verified,
domainType: "ns",
domainId: orgDomain.domainId,
subdomain: subdomain
});
} else if (userInputLower === baseDomainLower) {
// Exact match for base domain
options.push({
id: `org-${orgDomain.domainId}`,
domain: orgDomain.baseDomain,
type: "organization",
verified: orgDomain.verified,
domainType: "ns",
domainId: orgDomain.domainId
});
}
}
});
// Add provided domain options (always try to match provided domains)
availableOptions.forEach((option) => {
options.push({
id: `provided-${option.domainNamespaceId}`,
domain: option.fullDomain,
type: "provided",
domainNamespaceId: option.domainNamespaceId,
domainId: option.domainId,
});
});
// Sort options
return options.sort((a, b) => {
const comparison = a.domain.localeCompare(b.domain);
return sortOrder === "asc" ? comparison : -comparison;
});
};
const domainOptions = generateDomainOptions();
// Filter options based on active tab
const filteredOptions = domainOptions.filter((option) => {
if (activeTab === "all") return true;
return option.type === activeTab;
});
// Separate organization and provided options for pagination
const organizationOptions = filteredOptions.filter(
(opt) => opt.type === "organization"
);
const allProvidedOptions = filteredOptions.filter(
(opt) => opt.type === "provided"
);
const providedOptions = allProvidedOptions.slice(0, providedDomainsShown);
const hasMoreProvided = allProvidedOptions.length > providedDomainsShown;
// Handle option selection
const handleOptionSelect = (option: DomainOption) => {
setSelectedOption(option);
if (option.type === "organization") {
if (option.domainType === "cname") {
onDomainChange?.({
domainId: option.domainId!,
type: "organization",
subdomain: undefined,
fullDomain: option.domain,
baseDomain: option.domain
});
} else if (option.domainType === "ns") {
const subdomain = option.subdomain || "";
onDomainChange?.({
domainId: option.domainId!,
type: "organization",
subdomain: subdomain || undefined,
fullDomain: option.domain,
baseDomain: option.domain
});
}
} else if (option.type === "provided") {
// Extract subdomain from full domain
const parts = option.domain.split(".");
const subdomain = parts[0];
const baseDomain = parts.slice(1).join(".");
onDomainChange?.({
domainId: option.domainId!,
domainNamespaceId: option.domainNamespaceId,
type: "provided",
subdomain: subdomain,
fullDomain: option.domain,
baseDomain: baseDomain
});
}
};
return (
<div className="space-y-6">
{/* Domain Input */}
<div className="space-y-2">
<Label htmlFor="domain-input">
{t("domainPickerEnterDomain")}
</Label>
<Input
id="domain-input"
value={userInput}
onChange={(e) => {
// Only allow letters, numbers, hyphens, and periods
const validInput = e.target.value.replace(
/[^a-zA-Z0-9.-]/g,
""
);
setUserInput(validInput);
}}
/>
<p className="text-xs text-muted-foreground">
{t("domainPickerDescription")}
</p>
</div>
{/* Tabs and Sort Toggle */}
<div className="flex justify-between items-center">
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(
value as "all" | "organization" | "provided"
)
}
>
<TabsList>
<TabsTrigger value="all">
{t("domainPickerTabAll")}
</TabsTrigger>
<TabsTrigger value="organization">
{t("domainPickerTabOrganization")}
</TabsTrigger>
<TabsTrigger value="provided">
{t("domainPickerTabProvided")}
</TabsTrigger>
</TabsList>
</Tabs>
<Button
variant="outline"
size="sm"
onClick={() =>
setSortOrder(sortOrder === "asc" ? "desc" : "asc")
}
>
<ArrowUpDown className="h-4 w-4 mr-2" />
{sortOrder === "asc"
? t("domainPickerSortAsc")
: t("domainPickerSortDesc")}
</Button>
</div>
{/* Loading State */}
{isChecking && (
<div className="flex items-center justify-center p-8">
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
<span>{t("domainPickerCheckingAvailability")}</span>
</div>
</div>
)}
{/* No Options */}
{!isChecking &&
filteredOptions.length === 0 &&
userInput.trim() && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{t("domainPickerNoMatchingDomains", { userInput })}
</AlertDescription>
</Alert>
)}
{/* Domain Options */}
{!isChecking && filteredOptions.length > 0 && (
<div className="space-y-4">
{/* Organization Domains */}
{organizationOptions.length > 0 && (
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Building2 className="h-4 w-4" />
<h4 className="text-sm font-medium">
{t("domainPickerOrganizationDomains")}
</h4>
</div>
<div className="grid gap-2">
{organizationOptions.map((option) => (
<div
key={option.id}
className={cn(
"transition-all p-3 rounded-lg border",
selectedOption?.id === option.id
? "border-primary bg-primary/5"
: "border-input",
option.verified
? "cursor-pointer hover:bg-accent"
: "cursor-not-allowed opacity-60"
)}
onClick={() =>
option.verified && handleOptionSelect(option)
}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center space-x-2">
<p className="font-mono text-sm">
{option.domain}
</p>
{/* <Badge */}
{/* variant={ */}
{/* option.domainType === */}
{/* "ns" */}
{/* ? "default" */}
{/* : "secondary" */}
{/* } */}
{/* > */}
{/* {option.domainType} */}
{/* </Badge> */}
{option.verified ? (
<CheckCircle2 className="h-3 w-3 text-green-500" />
) : (
<AlertCircle className="h-3 w-3 text-yellow-500" />
)}
</div>
{option.subdomain && (
<p className="text-xs text-muted-foreground mt-1">
{t(
"domainPickerSubdomain",
{
subdomain:
option.subdomain
}
)}
</p>
)}
{!option.verified && (
<p className="text-xs text-yellow-600 mt-1">
Domain is unverified
</p>
)}
</div>
{selectedOption?.id ===
option.id && (
<CheckCircle2 className="h-4 w-4 text-primary" />
)}
</div>
</div>
))}
</div>
</div>
)}
{/* Provided Domains */}
{providedOptions.length > 0 && (
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Zap className="h-4 w-4" />
<div className="text-sm font-medium">
{t("domainPickerProvidedDomains")}
</div>
</div>
<div className="grid gap-2">
{providedOptions.map((option) => (
<div
key={option.id}
className={cn(
"transition-all p-3 rounded-lg border",
selectedOption?.id === option.id
? "border-primary bg-primary/5"
: "border-input",
"cursor-pointer hover:bg-accent"
)}
onClick={() =>
handleOptionSelect(option)
}
>
<div className="flex items-center justify-between">
<div>
<p className="font-mono text-sm">
{option.domain}
</p>
<p className="text-xs text-muted-foreground">
{t(
"domainPickerNamespace",
{
namespace:
option.domainNamespaceId as string
}
)}
</p>
</div>
{selectedOption?.id ===
option.id && (
<CheckCircle2 className="h-4 w-4 text-primary" />
)}
</div>
</div>
))}
</div>
{hasMoreProvided && (
<Button
variant="outline"
size="sm"
onClick={() =>
setProvidedDomainsShown(
(prev) => prev + 3
)
}
className="w-full"
>
{t("domainPickerShowMore")}
</Button>
)}
</div>
)}
</div>
)}
</div>
);
}
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
func(...args);
}, wait);
};
}

View File

@@ -30,8 +30,9 @@ export async function Layout({
}: LayoutProps) {
const allCookies = await cookies();
const sidebarStateCookie = allCookies.get("pangolin-sidebar-state")?.value;
const initialSidebarCollapsed = sidebarStateCookie === "collapsed" ||
const initialSidebarCollapsed =
sidebarStateCookie === "collapsed" ||
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
return (
@@ -49,7 +50,7 @@ export async function Layout({
{/* Main content area */}
<div
className={cn(
"flex-1 flex flex-col h-full min-w-0",
"flex-1 flex flex-col h-full min-w-0 relative",
!showSidebar && "w-full"
)}
>
@@ -69,7 +70,10 @@ export async function Layout({
{/* Main content */}
<main className="flex-1 overflow-y-auto p-3 md:p-6 w-full">
<div className="container mx-auto max-w-12xl mb-12">
<div className={cn(
"container mx-auto max-w-12xl mb-12",
showHeader && "md:pt-20" // Add top padding only on desktop to account for fixed header
)}>
{children}
</div>
</main>

View File

@@ -7,6 +7,8 @@ import Link from "next/link";
import ProfileIcon from "@app/components/ProfileIcon";
import ThemeSwitcher from "@app/components/ThemeSwitcher";
import { useTheme } from "next-themes";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Badge } from "./ui/badge";
interface LayoutHeaderProps {
showTopBar: boolean;
@@ -15,6 +17,7 @@ interface LayoutHeaderProps {
export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
const { theme } = useTheme();
const [path, setPath] = useState<string>("");
const { env } = useEnvContext();
useEffect(() => {
function getPath() {
@@ -56,7 +59,6 @@ export function LayoutHeader({ showTopBar }: LayoutHeaderProps) {
</Link>
</div>
{/* Profile controls on the right */}
{showTopBar && (
<div className="flex items-center space-x-2">
<ThemeSwitcher />

View File

@@ -152,7 +152,7 @@ export function LayoutSidebar({
onClick={() =>
setIsSidebarCollapsed(!isSidebarCollapsed)
}
className="cursor-pointer absolute -right-2.5 top-1/2 transform -translate-y-1/2 w-2 h-8 rounded-full flex items-center justify-center transition-all duration-200 ease-in-out hover:scale-110 group"
className="cursor-pointer absolute -right-2.5 top-1/2 transform -translate-y-1/2 w-2 h-8 rounded-full flex items-center justify-center transition-all duration-200 ease-in-out hover:scale-110 group z-[60]"
aria-label={
isSidebarCollapsed
? "Expand sidebar"

View File

@@ -80,8 +80,8 @@ export function OrgSelector({ orgId, orgs, isCollapsed = false }: OrgSelectorPro
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command className="rounded-lg">
<CommandInput
placeholder={t('searchProgress')}
<CommandInput
placeholder={t('searchProgress')}
className="border-0 focus:ring-0"
/>
<CommandEmpty className="py-6 text-center">

View File

@@ -11,6 +11,7 @@ import {
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { ArrowRight, Plus } from "lucide-react";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
interface Organization {
@@ -29,6 +30,8 @@ export default function OrganizationLanding({
}: OrganizationLandingProps) {
const [selectedOrg, setSelectedOrg] = useState<string | null>(null);
const { env } = useEnvContext();
const handleOrgClick = (orgId: string) => {
setSelectedOrg(orgId);
};

View File

@@ -19,7 +19,7 @@ export function SettingsSectionForm({
}: {
children: React.ReactNode;
}) {
return <div className="max-w-xl">{children}</div>;
return <div className="max-w-xl space-y-4">{children}</div>;
}
export function SettingsSectionTitle({

View File

@@ -48,6 +48,7 @@ export function SidebarNav({
const niceId = params.niceId as string;
const resourceId = params.resourceId as string;
const userId = params.userId as string;
const apiKeyId = params.apiKeyId as string;
const clientId = params.clientId as string;
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
const { user } = useUserContext();
@@ -59,6 +60,7 @@ export function SidebarNav({
.replace("{niceId}", niceId)
.replace("{resourceId}", resourceId)
.replace("{userId}", userId)
.replace("{apiKeyId}", apiKeyId)
.replace("{clientId}", clientId);
}

View File

@@ -67,7 +67,8 @@ export default function SupporterStatus({ isCollapsed = false }: SupporterStatus
const [keyOpen, setKeyOpen] = useState(false);
const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false);
const api = createApiClient(useEnvContext());
const { env } = useEnvContext();
const api = createApiClient({ env });
const t = useTranslations();
const formSchema = z.object({

View File

@@ -497,7 +497,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
<div className="w-full">
<div
className={cn(
`flex flex-row flex-wrap items-center gap-1.5 p-1.5 w-full rounded-md border border-input bg-background text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50`,
`flex flex-row flex-wrap items-center gap-1.5 p-1.5 w-full rounded-md border border-input text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50`,
styleClasses?.inlineTagsContainer
)}
>

View File

@@ -16,9 +16,9 @@ const badgeVariants = cva(
destructive:
"border-transparent bg-destructive text-destructive-foreground",
outline: "text-foreground",
green: "border-transparent bg-green-500",
yellow: "border-transparent bg-yellow-500",
red: "border-transparent bg-red-300",
green: "border-green-600 bg-green-500/20 text-green-700 dark:text-green-300",
yellow: "border-yellow-600 bg-yellow-500/20 text-yellow-700 dark:text-yellow-300",
red: "border-red-400 bg-red-300/20 text-red-600 dark:text-red-300",
},
},
defaultVariants: {

View File

@@ -71,7 +71,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
disabled={loading || props.disabled} // Disable button when loading
{...props}
>
{/* {loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} */}
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{props.children}
</Comp>
);

View File

@@ -107,8 +107,8 @@ export function DataTable<TData, TValue>({
</div>
<div className="flex items-center gap-2 sm:justify-end">
{onRefresh && (
<Button
variant="outline"
<Button
variant="outline"
onClick={onRefresh}
disabled={isRefreshing}
loading={isRefreshing}
@@ -182,4 +182,4 @@ export function DataTable<TData, TValue>({
</Card>
</div>
);
}
}

View File

@@ -3,24 +3,60 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@app/lib/cn";
import { cva, type VariantProps } from "class-variance-authority";
const progressVariants = cva(
"border relative h-2 w-full overflow-hidden rounded-full",
{
variants: {
variant: {
default: "bg-muted",
success: "bg-muted",
warning: "bg-muted",
danger: "bg-muted"
}
},
defaultVariants: {
variant: "default"
}
}
);
const indicatorVariants = cva(
"h-full w-full flex-1 transition-all",
{
variants: {
variant: {
default: "bg-primary",
success: "bg-green-500",
warning: "bg-yellow-500",
danger: "bg-red-500"
}
},
defaultVariants: {
variant: "default"
}
}
);
type ProgressProps = React.ComponentProps<typeof ProgressPrimitive.Root> &
VariantProps<typeof progressVariants>;
function Progress({
className,
value,
variant,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
}: ProgressProps) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"border relative h-2 w-full overflow-hidden rounded-full",
className
)}
className={cn(progressVariants({ variant }), className)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
className={cn(indicatorVariants({ variant }))}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>

View File

@@ -47,7 +47,9 @@ export function pullEnv(): Env {
? true
: false,
enableClients:
process.env.FLAGS_ENABLE_CLIENTS === "true" ? true : false
}
process.env.FLAGS_ENABLE_CLIENTS === "true" ? true : false,
hideSupporterKey:
process.env.HIDE_SUPPORTER_KEY === "true" ? true : false
},
};
}

View File

@@ -25,5 +25,6 @@ export type Env = {
disableLocalSites: boolean;
disableBasicWireguardSites: boolean;
enableClients: boolean;
};
hideSupporterKey: boolean;
},
};