mirror of
https://github.com/fosrl/pangolin.git
synced 2025-08-30 14:39:29 +02:00
improve site and resource info cards and other small visual tweaks
This commit is contained in:
parent
e6263567a9
commit
172e0f07d5
31 changed files with 469 additions and 332 deletions
|
@ -33,10 +33,20 @@ export const ConfirmPasswordReset = ({ email }: Props) => {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Body className="font-sans">
|
<Body className="font-sans relative">
|
||||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-bold text-orange-500">
|
||||||
|
Pangolin
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{new Date().toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||||
Your password has been successfully reset
|
Password Reset Confirmation
|
||||||
</Heading>
|
</Heading>
|
||||||
<Text className="text-base text-gray-700 mt-4">
|
<Text className="text-base text-gray-700 mt-4">
|
||||||
Hi {email || "there"},
|
Hi {email || "there"},
|
||||||
|
@ -46,12 +56,10 @@ export const ConfirmPasswordReset = ({ email }: Props) => {
|
||||||
reset. If you made this change, no further action is
|
reset. If you made this change, no further action is
|
||||||
required.
|
required.
|
||||||
</Text>
|
</Text>
|
||||||
<Section className="text-center my-6">
|
<Text className="text-base text-gray-700">
|
||||||
<Text className="text-base text-gray-700">
|
If you did not request this change, please contact
|
||||||
If you did not request this change, please
|
our support team immediately.
|
||||||
contact our support team immediately.
|
</Text>
|
||||||
</Text>
|
|
||||||
</Section>
|
|
||||||
<Text className="text-base text-gray-700 mt-2">
|
<Text className="text-base text-gray-700 mt-2">
|
||||||
Thank you for keeping your account secure.
|
Thank you for keeping your account secure.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
|
@ -37,8 +37,18 @@ export const ResetPasswordCode = ({ email, code, link }: Props) => {
|
||||||
>
|
>
|
||||||
<Body className="font-sans">
|
<Body className="font-sans">
|
||||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-bold text-orange-500">
|
||||||
|
Pangolin
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{new Date().toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||||
You've requested to reset your password
|
Password Reset Request
|
||||||
</Heading>
|
</Heading>
|
||||||
<Text className="text-base text-gray-700 mt-4">
|
<Text className="text-base text-gray-700 mt-4">
|
||||||
Hi {email || "there"},
|
Hi {email || "there"},
|
||||||
|
@ -51,7 +61,7 @@ export const ResetPasswordCode = ({ email, code, link }: Props) => {
|
||||||
and follow the instructions to reset your password,
|
and follow the instructions to reset your password,
|
||||||
or manually enter the following code:
|
or manually enter the following code:
|
||||||
</Text>
|
</Text>
|
||||||
<Section className="text-center my-6">
|
<Section className="text-center">
|
||||||
<Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl">
|
<Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl">
|
||||||
{code}
|
{code}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
|
@ -43,6 +43,16 @@ export const ResourceOTPCode = ({
|
||||||
>
|
>
|
||||||
<Body className="font-sans">
|
<Body className="font-sans">
|
||||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-bold text-orange-500">
|
||||||
|
Pangolin
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{new Date().toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||||
Your One-Time Password
|
Your One-Time Password
|
||||||
</Heading>
|
</Heading>
|
||||||
|
@ -56,12 +66,11 @@ export const ResourceOTPCode = ({
|
||||||
<strong>{organizationName}</strong>. Use the OTP
|
<strong>{organizationName}</strong>. Use the OTP
|
||||||
below to complete your authentication:
|
below to complete your authentication:
|
||||||
</Text>
|
</Text>
|
||||||
<Section className="text-center my-6">
|
<Section className="text-center">
|
||||||
<Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl">
|
<Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl">
|
||||||
{otp}
|
{otp}
|
||||||
</Text>
|
</Text>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Text className="text-sm text-gray-500 mt-6">
|
<Text className="text-sm text-gray-500 mt-6">
|
||||||
Best regards,
|
Best regards,
|
||||||
<br />
|
<br />
|
||||||
|
|
|
@ -46,8 +46,18 @@ export const SendInviteLink = ({
|
||||||
>
|
>
|
||||||
<Body className="font-sans">
|
<Body className="font-sans">
|
||||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-bold text-orange-500">
|
||||||
|
Pangolin
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{new Date().toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||||
You're invited to join a Fossorial organization
|
You're Invite to Join {orgName}
|
||||||
</Heading>
|
</Heading>
|
||||||
<Text className="text-base text-gray-700 mt-4">
|
<Text className="text-base text-gray-700 mt-4">
|
||||||
Hi {email || "there"},
|
Hi {email || "there"},
|
||||||
|
@ -65,12 +75,12 @@ export const SendInviteLink = ({
|
||||||
{expiresInDays === "1" ? "day" : "days"}.
|
{expiresInDays === "1" ? "day" : "days"}.
|
||||||
</b>
|
</b>
|
||||||
</Text>
|
</Text>
|
||||||
<Section className="text-center my-6">
|
<Section className="text-center">
|
||||||
<Button
|
<Button
|
||||||
href={inviteLink}
|
href={inviteLink}
|
||||||
className="rounded-lg bg-primary px-[12px] py-[9px] text-center font-semibold text-white cursor-pointer text-xl"
|
className="rounded-lg bg-primary px-[12px] py-[9px] text-center font-semibold text-white cursor-pointer text-xl"
|
||||||
>
|
>
|
||||||
Accept invitation to {orgName}
|
Accept Invite to {orgName}
|
||||||
</Button>
|
</Button>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|
|
@ -36,6 +36,16 @@ export const TwoFactorAuthNotification = ({ email, enabled }: Props) => {
|
||||||
>
|
>
|
||||||
<Body className="font-sans">
|
<Body className="font-sans">
|
||||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-bold text-orange-500">
|
||||||
|
Pangolin
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{new Date().toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||||
Two-Factor Authentication{" "}
|
Two-Factor Authentication{" "}
|
||||||
{enabled ? "Enabled" : "Disabled"}
|
{enabled ? "Enabled" : "Disabled"}
|
||||||
|
@ -48,22 +58,19 @@ export const TwoFactorAuthNotification = ({ email, enabled }: Props) => {
|
||||||
has been successfully{" "}
|
has been successfully{" "}
|
||||||
{enabled ? "enabled" : "disabled"} on your account.
|
{enabled ? "enabled" : "disabled"} on your account.
|
||||||
</Text>
|
</Text>
|
||||||
<Section className="text-center my-6">
|
{enabled ? (
|
||||||
{enabled ? (
|
<Text className="text-base text-gray-700">
|
||||||
<Text className="text-base text-gray-700">
|
With Two-Factor Authentication enabled, your
|
||||||
With Two-Factor Authentication enabled, your
|
account is now more secure. Please ensure you
|
||||||
account is now more secure. Please ensure
|
keep your authentication method safe.
|
||||||
you keep your authentication method safe.
|
</Text>
|
||||||
</Text>
|
) : (
|
||||||
) : (
|
<Text className="text-base text-gray-700">
|
||||||
<Text className="text-base text-gray-700">
|
With Two-Factor Authentication disabled, your
|
||||||
With Two-Factor Authentication disabled,
|
account may be less secure. We recommend
|
||||||
your account may be less secure. We
|
enabling it to protect your account.
|
||||||
recommend enabling it to protect your
|
</Text>
|
||||||
account.
|
)}
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
<Text className="text-base text-gray-700 mt-2">
|
<Text className="text-base text-gray-700 mt-2">
|
||||||
If you did not make this change, please contact our
|
If you did not make this change, please contact our
|
||||||
support team immediately.
|
support team immediately.
|
||||||
|
|
|
@ -41,17 +41,28 @@ export const VerifyEmail = ({
|
||||||
>
|
>
|
||||||
<Body className="font-sans">
|
<Body className="font-sans">
|
||||||
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm font-bold text-orange-500">
|
||||||
|
Pangolin
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{new Date().toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
<Heading className="text-2xl font-semibold text-gray-800 text-center">
|
||||||
Please verify your email
|
Please Verify Your Email
|
||||||
</Heading>
|
</Heading>
|
||||||
<Text className="text-base text-gray-700 mt-4">
|
<Text className="text-base text-gray-700 mt-4">
|
||||||
Hi {username || "there"},
|
Hi {username || "there"},
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-base text-gray-700 mt-2">
|
<Text className="text-base text-gray-700 mt-2">
|
||||||
You’ve requested to verify your email. Please use
|
You’ve requested to verify your email. Please use
|
||||||
the code below to complete the verification process upon logging in.
|
the code below to complete the verification process
|
||||||
|
upon logging in.
|
||||||
</Text>
|
</Text>
|
||||||
<Section className="text-center my-6">
|
<Section className="text-center">
|
||||||
<Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl">
|
<Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl">
|
||||||
{verificationCode}
|
{verificationCode}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
|
@ -25,7 +25,12 @@ const createSiteSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
exitNodeId: z.number().int().positive(),
|
exitNodeId: z.number().int().positive(),
|
||||||
subdomain: z.string().min(1).max(255).optional(),
|
// subdomain: z
|
||||||
|
// .string()
|
||||||
|
// .min(1)
|
||||||
|
// .max(255)
|
||||||
|
// .transform((val) => val.toLowerCase())
|
||||||
|
// .optional(),
|
||||||
pubKey: z.string().optional(),
|
pubKey: z.string().optional(),
|
||||||
subnet: z.string(),
|
subnet: z.string(),
|
||||||
newtId: z.string().optional(),
|
newtId: z.string().optional(),
|
||||||
|
|
|
@ -23,13 +23,25 @@ const getSiteSchema = z
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
export type GetSiteResponse = {
|
async function query(siteId?: number, niceId?: string, orgId?: string) {
|
||||||
siteId: number;
|
if (siteId) {
|
||||||
name: string;
|
const [res] = await db
|
||||||
subdomain: string;
|
.select()
|
||||||
subnet: string;
|
.from(sites)
|
||||||
type: string;
|
.where(eq(sites.siteId, siteId))
|
||||||
};
|
.limit(1);
|
||||||
|
return res;
|
||||||
|
} else if (niceId && orgId) {
|
||||||
|
const [res] = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(and(eq(sites.niceId, niceId), eq(sites.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GetSiteResponse = NonNullable<Awaited<ReturnType<typeof query>>>;
|
||||||
|
|
||||||
export async function getSite(
|
export async function getSite(
|
||||||
req: Request,
|
req: Request,
|
||||||
|
@ -49,42 +61,14 @@ export async function getSite(
|
||||||
|
|
||||||
const { siteId, niceId, orgId } = parsedParams.data;
|
const { siteId, niceId, orgId } = parsedParams.data;
|
||||||
|
|
||||||
let site;
|
const site = await query(siteId, niceId, orgId);
|
||||||
if (siteId) {
|
|
||||||
site = await db
|
|
||||||
.select()
|
|
||||||
.from(sites)
|
|
||||||
.where(eq(sites.siteId, siteId))
|
|
||||||
.limit(1);
|
|
||||||
} else if (niceId && orgId) {
|
|
||||||
site = await db
|
|
||||||
.select()
|
|
||||||
.from(sites)
|
|
||||||
.where(and(eq(sites.niceId, niceId), eq(sites.orgId, orgId)))
|
|
||||||
.limit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!site) {
|
if (!site) {
|
||||||
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
|
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (site.length === 0) {
|
return response<GetSiteResponse>(res, {
|
||||||
return next(
|
data: site,
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Site with ID ${siteId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: {
|
|
||||||
siteId: site[0].siteId,
|
|
||||||
niceId: site[0].niceId,
|
|
||||||
name: site[0].name,
|
|
||||||
subnet: site[0].subnet,
|
|
||||||
type: site[0].type
|
|
||||||
},
|
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Site retrieved successfully",
|
message: "Site retrieved successfully",
|
||||||
|
|
|
@ -18,7 +18,12 @@ const updateSiteParamsSchema = z
|
||||||
const updateSiteBodySchema = z
|
const updateSiteBodySchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
subdomain: z.string().min(1).max(255).optional()
|
// subdomain: z
|
||||||
|
// .string()
|
||||||
|
// .min(1)
|
||||||
|
// .max(255)
|
||||||
|
// .transform((val) => val.toLowerCase())
|
||||||
|
// .optional()
|
||||||
// pubKey: z.string().optional(),
|
// pubKey: z.string().optional(),
|
||||||
// subnet: z.string().optional(),
|
// subnet: z.string().optional(),
|
||||||
// exitNode: z.number().int().positive().optional(),
|
// exitNode: z.number().int().positive().optional(),
|
||||||
|
|
|
@ -6,4 +6,5 @@ export const subdomainSchema = z
|
||||||
/^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/,
|
/^(?!:\/\/)([a-zA-Z0-9-_]+\.)*[a-zA-Z0-9-_]+$/,
|
||||||
"Invalid subdomain format"
|
"Invalid subdomain format"
|
||||||
)
|
)
|
||||||
.min(1, "Subdomain must be at least 1 character long");
|
.min(1, "Subdomain must be at least 1 character long")
|
||||||
|
.transform((val) => val.toLowerCase());
|
||||||
|
|
|
@ -13,6 +13,8 @@ type RolesPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function RolesPage(props: RolesPageProps) {
|
export default async function RolesPage(props: RolesPageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
|
|
|
@ -11,9 +11,10 @@ import {
|
||||||
BreadcrumbLink,
|
BreadcrumbLink,
|
||||||
BreadcrumbList,
|
BreadcrumbList,
|
||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator
|
||||||
} from "@/components/ui/breadcrumb";
|
} from "@/components/ui/breadcrumb";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { cache } from "react";
|
||||||
|
|
||||||
interface UserLayoutProps {
|
interface UserLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
@ -27,10 +28,13 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
|
||||||
|
|
||||||
let user = null;
|
let user = null;
|
||||||
try {
|
try {
|
||||||
const res = await internal.get<AxiosResponse<GetOrgUserResponse>>(
|
const getOrgUser = cache(async () =>
|
||||||
`/org/${params.orgId}/user/${params.userId}`,
|
internal.get<AxiosResponse<GetOrgUserResponse>>(
|
||||||
await authCookieHeader(),
|
`/org/${params.orgId}/user/${params.userId}`,
|
||||||
|
await authCookieHeader()
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
const res = await getOrgUser();
|
||||||
user = res.data.data;
|
user = res.data.data;
|
||||||
} catch {
|
} catch {
|
||||||
redirect(`/${params.orgId}/settings/sites`);
|
redirect(`/${params.orgId}/settings/sites`);
|
||||||
|
@ -39,8 +43,8 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
|
||||||
const sidebarNavItems = [
|
const sidebarNavItems = [
|
||||||
{
|
{
|
||||||
title: "Access Controls",
|
title: "Access Controls",
|
||||||
href: "/{orgId}/settings/access/users/{userId}/access-controls",
|
href: "/{orgId}/settings/access/users/{userId}/access-controls"
|
||||||
},
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -15,6 +15,8 @@ type UsersPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function UsersPage(props: UsersPageProps) {
|
export default async function UsersPage(props: UsersPageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
|
|
|
@ -382,8 +382,8 @@ export default function ResourceAuthenticationPage() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-12 lg:max-w-2xl">
|
<div className="space-y-12">
|
||||||
<section className="space-y-8">
|
<section className="space-y-8 lg:max-w-2xl">
|
||||||
<SettingsSectionTitle
|
<SettingsSectionTitle
|
||||||
title="Users & Roles"
|
title="Users & Roles"
|
||||||
description="Configure which users and roles can visit this resource"
|
description="Configure which users and roles can visit this resource"
|
||||||
|
@ -541,7 +541,7 @@ export default function ResourceAuthenticationPage() {
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<section className="space-y-8">
|
<section className="space-y-8 lg:max-w-2xl">
|
||||||
<SettingsSectionTitle
|
<SettingsSectionTitle
|
||||||
title="Authentication Methods"
|
title="Authentication Methods"
|
||||||
description="Allow access to the resource via additional auth methods"
|
description="Allow access to the resource via additional auth methods"
|
||||||
|
@ -613,109 +613,105 @@ export default function ResourceAuthenticationPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{env.EMAIL_ENABLED === "true" && (
|
<Separator />
|
||||||
<>
|
|
||||||
<Separator />
|
<section className="space-y-8 lg:max-w-2xl">
|
||||||
<div>
|
{env.EMAIL_ENABLED === "true" && (
|
||||||
<div className="flex items-center space-x-2 mb-2">
|
<>
|
||||||
<Switch
|
<div>
|
||||||
id="whitelist-toggle"
|
<div className="flex items-center space-x-2 mb-2">
|
||||||
defaultChecked={
|
<Switch
|
||||||
resource.emailWhitelistEnabled
|
id="whitelist-toggle"
|
||||||
}
|
defaultChecked={
|
||||||
onCheckedChange={(val) =>
|
resource.emailWhitelistEnabled
|
||||||
setWhitelistEnabled(val)
|
}
|
||||||
}
|
onCheckedChange={(val) =>
|
||||||
/>
|
setWhitelistEnabled(val)
|
||||||
<Label htmlFor="whitelist-toggle">
|
}
|
||||||
Email Whitelist
|
/>
|
||||||
</Label>
|
<Label htmlFor="whitelist-toggle">
|
||||||
</div>
|
Email Whitelist
|
||||||
<span className="text-muted-foreground text-sm">
|
</Label>
|
||||||
Enable resource whitelist to require
|
|
||||||
email-based authentication (one-time
|
|
||||||
passwords) for resource access.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
Enable resource whitelist to require email-based
|
||||||
|
authentication (one-time passwords) for resource
|
||||||
|
access.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{whitelistEnabled && (
|
{whitelistEnabled && (
|
||||||
<Form {...whitelistForm}>
|
<Form {...whitelistForm}>
|
||||||
<form className="space-y-4">
|
<form className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
control={whitelistForm.control}
|
control={whitelistForm.control}
|
||||||
name="emails"
|
name="emails"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex flex-col items-start">
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
Whitelisted Emails
|
Whitelisted Emails
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
{/* @ts-ignore */}
|
{/* @ts-ignore */}
|
||||||
<TagInput
|
<TagInput
|
||||||
{...field}
|
{...field}
|
||||||
activeTagIndex={
|
activeTagIndex={
|
||||||
activeEmailTagIndex
|
activeEmailTagIndex
|
||||||
}
|
}
|
||||||
validateTag={(
|
validateTag={(tag) => {
|
||||||
tag
|
return z
|
||||||
) => {
|
.string()
|
||||||
return z
|
.email()
|
||||||
.string()
|
.safeParse(tag)
|
||||||
.email()
|
.success;
|
||||||
.safeParse(
|
}}
|
||||||
tag
|
setActiveTagIndex={
|
||||||
).success;
|
setActiveEmailTagIndex
|
||||||
}}
|
}
|
||||||
setActiveTagIndex={
|
placeholder="Enter an email"
|
||||||
setActiveEmailTagIndex
|
tags={
|
||||||
}
|
whitelistForm.getValues()
|
||||||
placeholder="Enter an email"
|
.emails
|
||||||
tags={
|
}
|
||||||
whitelistForm.getValues()
|
setTags={(newRoles) => {
|
||||||
.emails
|
whitelistForm.setValue(
|
||||||
}
|
"emails",
|
||||||
setTags={(
|
newRoles as [
|
||||||
newRoles
|
Tag,
|
||||||
) => {
|
...Tag[]
|
||||||
whitelistForm.setValue(
|
]
|
||||||
"emails",
|
);
|
||||||
newRoles as [
|
}}
|
||||||
Tag,
|
allowDuplicates={false}
|
||||||
...Tag[]
|
sortTags={true}
|
||||||
]
|
styleClasses={{
|
||||||
);
|
tag: {
|
||||||
}}
|
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
|
||||||
allowDuplicates={
|
},
|
||||||
false
|
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
|
||||||
}
|
inlineTagsContainer:
|
||||||
sortTags={true}
|
"bg-transparent p-2"
|
||||||
styleClasses={{
|
}}
|
||||||
tag: {
|
/>
|
||||||
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
|
</FormControl>
|
||||||
},
|
</FormItem>
|
||||||
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
|
)}
|
||||||
inlineTagsContainer:
|
/>
|
||||||
"bg-transparent p-2"
|
</form>
|
||||||
}}
|
</Form>
|
||||||
/>
|
)}
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
loading={loadingSaveWhitelist}
|
loading={loadingSaveWhitelist}
|
||||||
disabled={loadingSaveWhitelist}
|
disabled={loadingSaveWhitelist}
|
||||||
onClick={saveWhitelist}
|
onClick={saveWhitelist}
|
||||||
>
|
>
|
||||||
Save Whitelist
|
Save Whitelist
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Card } from "@/components/ui/card";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
@ -14,7 +13,14 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useOrgContext } from "@app/hooks/useOrgContext";
|
import { useOrgContext } from "@app/hooks/useOrgContext";
|
||||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||||
import Link from "next/link";
|
import { Separator } from "@app/components/ui/separator";
|
||||||
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
|
import {
|
||||||
|
InfoSection,
|
||||||
|
InfoSectionContent,
|
||||||
|
InfoSections,
|
||||||
|
InfoSectionTitle
|
||||||
|
} from "@app/components/InfoSection";
|
||||||
|
|
||||||
type ResourceInfoBoxType = {};
|
type ResourceInfoBoxType = {};
|
||||||
|
|
||||||
|
@ -28,86 +34,48 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||||
resource.subdomain
|
resource.subdomain
|
||||||
}.${org.org.domain}`;
|
}.${org.org.domain}`;
|
||||||
|
|
||||||
const copyToClipboard = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(fullUrl);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to copy text: ", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert>
|
<Alert>
|
||||||
<InfoIcon className="h-4 w-4" />
|
<InfoIcon className="h-4 w-4" />
|
||||||
<AlertTitle className="font-semibold">
|
<AlertTitle className="font-semibold">
|
||||||
Resource Information
|
Resource Information
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription className="mt-3">
|
<AlertDescription className="mt-4">
|
||||||
<div className="space-y-3">
|
<InfoSections>
|
||||||
<div>
|
<InfoSection>
|
||||||
{authInfo.password ||
|
<InfoSectionTitle>Authentication</InfoSectionTitle>
|
||||||
authInfo.pincode ||
|
<InfoSectionContent>
|
||||||
authInfo.sso ||
|
{authInfo.password ||
|
||||||
authInfo.whitelist ? (
|
authInfo.pincode ||
|
||||||
<div className="flex items-center space-x-2 text-green-500">
|
authInfo.sso ||
|
||||||
<ShieldCheck />
|
authInfo.whitelist ? (
|
||||||
<span>
|
<div className="flex items-start space-x-2 text-green-500">
|
||||||
This resource is protected with at least one
|
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
||||||
auth method.
|
<span>
|
||||||
</span>
|
This resource is protected with at least
|
||||||
</div>
|
one auth method.
|
||||||
) : (
|
</span>
|
||||||
<div className="flex items-center space-x-2 text-yellow-500">
|
</div>
|
||||||
<ShieldOff />
|
|
||||||
<span>
|
|
||||||
This resource is not protected with any auth
|
|
||||||
method. Anyone can access this resource.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 bg-muted p-1 pl-3 rounded-md lg:max-w-xl">
|
|
||||||
<LinkIcon className="h-4 w-4" />
|
|
||||||
<a
|
|
||||||
href={fullUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm font-mono flex-grow hover:underline truncate"
|
|
||||||
>
|
|
||||||
{fullUrl}
|
|
||||||
</a>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={copyToClipboard}
|
|
||||||
className="ml-2"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<CheckIcon className="h-4 w-4 text-green-500" />
|
|
||||||
) : (
|
) : (
|
||||||
<CopyIcon className="h-4 w-4" />
|
<div className="flex items-center space-x-2 text-yellow-500">
|
||||||
|
<ShieldOff className="w-4 h-4" />
|
||||||
|
<span>
|
||||||
|
This resource is not protected with any
|
||||||
|
auth method. Anyone can access this
|
||||||
|
resource.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className="ml-2">
|
</InfoSectionContent>
|
||||||
{copied ? "Copied!" : "Copy"}
|
</InfoSection>
|
||||||
</span>
|
<Separator orientation="vertical" />
|
||||||
</Button>
|
<InfoSection>
|
||||||
</div>
|
<InfoSectionTitle>URL</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
{/* <p className="mt-3">
|
<CopyToClipboard text={fullUrl} isLink={true} />
|
||||||
To create a proxy to your private services,{" "}
|
</InfoSectionContent>
|
||||||
<Link
|
</InfoSection>
|
||||||
href={`/${org.org.orgId}/settings/resources/${resource.resourceId}/connectivity`}
|
</InfoSections>
|
||||||
className="text-primary hover:underline"
|
|
||||||
>
|
|
||||||
add targets
|
|
||||||
</Link>{" "}
|
|
||||||
to this resource
|
|
||||||
</p> */}
|
|
||||||
</div>
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
|
|
|
@ -429,7 +429,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<hr className="lg:max-w-2xl" />
|
<hr />
|
||||||
|
|
||||||
<section className="space-y-8">
|
<section className="space-y-8">
|
||||||
<SettingsSectionTitle
|
<SettingsSectionTitle
|
||||||
|
|
|
@ -29,6 +29,7 @@ import { formatAxiosError } from "@app/lib/utils";
|
||||||
import { useToast } from "@app/hooks/useToast";
|
import { useToast } from "@app/hooks/useToast";
|
||||||
import { createApiClient } from "@app/api";
|
import { createApiClient } from "@app/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
|
|
||||||
export type ResourceRow = {
|
export type ResourceRow = {
|
||||||
id: number;
|
id: number;
|
||||||
|
@ -162,55 +163,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center">
|
<CopyToClipboard text={resourceRow.domain} isLink={true} />
|
||||||
<Link
|
|
||||||
href={resourceRow.domain}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="hover:underline mr-2"
|
|
||||||
>
|
|
||||||
{resourceRow.domain}
|
|
||||||
</Link>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(
|
|
||||||
resourceRow.domain
|
|
||||||
);
|
|
||||||
const originalIcon = document.querySelector(
|
|
||||||
`#icon-${resourceRow.id}`
|
|
||||||
);
|
|
||||||
if (originalIcon) {
|
|
||||||
originalIcon.classList.add("hidden");
|
|
||||||
}
|
|
||||||
const checkIcon = document.querySelector(
|
|
||||||
`#check-icon-${resourceRow.id}`
|
|
||||||
);
|
|
||||||
if (checkIcon) {
|
|
||||||
checkIcon.classList.remove("hidden");
|
|
||||||
setTimeout(() => {
|
|
||||||
checkIcon.classList.add("hidden");
|
|
||||||
if (originalIcon) {
|
|
||||||
originalIcon.classList.remove(
|
|
||||||
"hidden"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Copy
|
|
||||||
id={`icon-${resourceRow.id}`}
|
|
||||||
className="h-4 w-4"
|
|
||||||
/>
|
|
||||||
<Check
|
|
||||||
id={`check-icon-${resourceRow.id}`}
|
|
||||||
className="hidden text-green-500 h-4 w-4"
|
|
||||||
/>
|
|
||||||
<span className="sr-only">Copy domain</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -13,6 +13,8 @@ type ResourcesPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function ResourcesPage(props: ResourcesPageProps) {
|
export default async function ResourcesPage(props: ResourcesPageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
let resources: ListResourcesResponse["resources"] = [];
|
let resources: ListResourcesResponse["resources"] = [];
|
||||||
|
|
|
@ -13,6 +13,8 @@ type ShareLinksPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function ShareLinksPage(props: ShareLinksPageProps) {
|
export default async function ShareLinksPage(props: ShareLinksPageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { InfoIcon } from "lucide-react";
|
||||||
|
import { useSiteContext } from "@app/hooks/useSiteContext";
|
||||||
|
import { Separator } from "@app/components/ui/separator";
|
||||||
|
import {
|
||||||
|
InfoSection,
|
||||||
|
InfoSectionContent,
|
||||||
|
InfoSections,
|
||||||
|
InfoSectionTitle
|
||||||
|
} from "@app/components/InfoSection";
|
||||||
|
|
||||||
|
type SiteInfoCardProps = {};
|
||||||
|
|
||||||
|
export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||||
|
const { site, updateSite } = useSiteContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert>
|
||||||
|
<InfoIcon className="h-4 w-4" />
|
||||||
|
<AlertTitle className="font-semibold">Site Information</AlertTitle>
|
||||||
|
<AlertDescription className="mt-4">
|
||||||
|
<InfoSections>
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>Status</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
{site.online ? (
|
||||||
|
<div className="text-green-500 flex items-center space-x-2">
|
||||||
|
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
|
||||||
|
<span>Online</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-neutral-500 flex items-center space-x-2">
|
||||||
|
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
|
||||||
|
<span>Offline</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
<InfoSection>
|
||||||
|
<InfoSectionTitle>Connection Type</InfoSectionTitle>
|
||||||
|
<InfoSectionContent>
|
||||||
|
{site.type === "newt"
|
||||||
|
? "Newt"
|
||||||
|
: site.type === "wireguard"
|
||||||
|
? "WireGuard"
|
||||||
|
: "Unknown"}
|
||||||
|
</InfoSectionContent>
|
||||||
|
</InfoSection>
|
||||||
|
</InfoSections>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
|
@ -67,7 +67,7 @@ export default function GeneralPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8 max-w-xl">
|
||||||
<SettingsSectionTitle
|
<SettingsSectionTitle
|
||||||
title="General Settings"
|
title="General Settings"
|
||||||
description="Configure the general settings for this site"
|
description="Configure the general settings for this site"
|
||||||
|
|
|
@ -13,8 +13,9 @@ import {
|
||||||
BreadcrumbItem,
|
BreadcrumbItem,
|
||||||
BreadcrumbList,
|
BreadcrumbList,
|
||||||
BreadcrumbPage,
|
BreadcrumbPage,
|
||||||
BreadcrumbSeparator,
|
BreadcrumbSeparator
|
||||||
} from "@app/components/ui/breadcrumb";
|
} from "@app/components/ui/breadcrumb";
|
||||||
|
import SiteInfoCard from "./components/SiteInfoCard";
|
||||||
|
|
||||||
interface SettingsLayoutProps {
|
interface SettingsLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
@ -30,7 +31,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||||
try {
|
try {
|
||||||
const res = await internal.get<AxiosResponse<GetSiteResponse>>(
|
const res = await internal.get<AxiosResponse<GetSiteResponse>>(
|
||||||
`/org/${params.orgId}/site/${params.niceId}`,
|
`/org/${params.orgId}/site/${params.niceId}`,
|
||||||
await authCookieHeader(),
|
await authCookieHeader()
|
||||||
);
|
);
|
||||||
site = res.data.data;
|
site = res.data.data;
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -40,8 +41,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||||
const sidebarNavItems = [
|
const sidebarNavItems = [
|
||||||
{
|
{
|
||||||
title: "General",
|
title: "General",
|
||||||
href: "/{orgId}/settings/sites/{niceId}/general",
|
href: "/{orgId}/settings/sites/{niceId}/general"
|
||||||
},
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -66,10 +67,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SiteProvider site={site}>
|
<SiteProvider site={site}>
|
||||||
<SidebarSettings
|
<SidebarSettings sidebarNavItems={sidebarNavItems}>
|
||||||
sidebarNavItems={sidebarNavItems}
|
<div className="mb-8">
|
||||||
limitWidth={true}
|
<SiteInfoCard />
|
||||||
>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</SidebarSettings>
|
</SidebarSettings>
|
||||||
</SiteProvider>
|
</SiteProvider>
|
||||||
|
|
|
@ -9,6 +9,8 @@ type SitesPageProps = {
|
||||||
params: Promise<{ orgId: string }>;
|
params: Promise<{ orgId: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function SitesPage(props: SitesPageProps) {
|
export default async function SitesPage(props: SitesPageProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
let sites: ListSitesResponse["sites"] = [];
|
let sites: ListSitesResponse["sites"] = [];
|
||||||
|
|
|
@ -51,23 +51,29 @@ export default async function RootLayout({
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
||||||
<footer className="w-full mt-6 py-3">
|
<footer className="w-full mt-12 py-3 mb-4">
|
||||||
<div className="container mx-auto flex justify-center items-center h-5 space-x-4 text-sm text-neutral-400 select-none">
|
<div className="container mx-auto flex flex-wrap justify-center items-center h-4 space-x-4 text-sm text-neutral-400 select-none">
|
||||||
<div>Built by Fossorial</div>
|
<div className="whitespace-nowrap">
|
||||||
|
Pangolin
|
||||||
|
</div>
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
<div className="whitespace-nowrap">
|
||||||
|
Built by Fossorial
|
||||||
|
</div>
|
||||||
<Separator orientation="vertical" />
|
<Separator orientation="vertical" />
|
||||||
<a
|
<a
|
||||||
href="https://github.com/fosrl/pangolin"
|
href="https://github.com/fosrl/pangolin"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label="GitHub"
|
aria-label="GitHub"
|
||||||
className="flex items-center space-x-3 underline"
|
className="flex items-center space-x-3 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<span>Open Source</span>
|
<span>Open Source</span>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
className="w-4 h-4"
|
className="w-3 h-3"
|
||||||
>
|
>
|
||||||
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
|
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
@ -75,7 +81,9 @@ export default async function RootLayout({
|
||||||
{version && (
|
{version && (
|
||||||
<>
|
<>
|
||||||
<Separator orientation="vertical" />
|
<Separator orientation="vertical" />
|
||||||
<div>v{version}</div>
|
<div className="whitespace-nowrap">
|
||||||
|
v{version}
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
52
src/components/CopyToClipboard.tsx
Normal file
52
src/components/CopyToClipboard.tsx
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import { Check, Copy } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
type CopyToClipboardProps = {
|
||||||
|
text: string;
|
||||||
|
isLink?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setCopied(false);
|
||||||
|
}, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
{isLink ? (
|
||||||
|
<Link
|
||||||
|
href={text}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:underline mr-2"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="mr-2">{text}</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="h-6 w-6 p-0 flex items-center justify-center cursor-pointer"
|
||||||
|
onClick={handleCopy}
|
||||||
|
>
|
||||||
|
{!copied ? (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Check className="text-green-500 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">Copy text</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CopyToClipboard;
|
|
@ -90,11 +90,19 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
|
||||||
|
|
||||||
const CredenzaContent = isDesktop ? DialogContent : SheetContent;
|
const CredenzaContent = isDesktop ? DialogContent : SheetContent;
|
||||||
|
|
||||||
return (
|
return isDesktop ? (
|
||||||
|
<CredenzaContent
|
||||||
|
className={cn("overflow-y-auto max-h-screen", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</CredenzaContent>
|
||||||
|
) : (
|
||||||
<CredenzaContent
|
<CredenzaContent
|
||||||
className={cn("overflow-y-auto max-h-screen", className)}
|
className={cn("overflow-y-auto max-h-screen", className)}
|
||||||
{...props}
|
{...props}
|
||||||
side={"bottom"}
|
side={"bottom"}
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</CredenzaContent>
|
</CredenzaContent>
|
||||||
|
|
31
src/components/InfoSection.tsx
Normal file
31
src/components/InfoSection.tsx
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
export function InfoSections({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:gap-4 gap-2 md:grid-cols-[1fr_auto_1fr] md:items-start">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfoSection({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className="space-y-1">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfoSectionTitle({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className="font-semibold">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfoSectionContent({
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <div className="break-words">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Divider() {
|
||||||
|
return (
|
||||||
|
<div className="hidden md:block border-l border-gray-300 h-auto mx-4"></div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -65,7 +65,7 @@ export default function ProfileIcon() {
|
||||||
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
|
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
|
||||||
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
|
<Disable2FaForm open={openDisable2fa} setOpen={setOpenDisable2fa} />
|
||||||
|
|
||||||
<div className="flex items-center gap-4 flex-grow min-w-0">
|
<div className="flex items-center md:gap-4 gap-2 flex-grow min-w-0">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
|
||||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||||
|
|
||||||
const sheetVariants = cva(
|
const sheetVariants = cva(
|
||||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-100 data-[state=open]:duration-300",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
side: {
|
side: {
|
||||||
|
@ -80,7 +80,7 @@ const SheetHeader = ({
|
||||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col space-y-2 text-center sm:text-left",
|
"flex flex-col text-center sm:text-left mb-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { GetSiteResponse } from "@server/routers/site/getSite";
|
||||||
import { createContext } from "react";
|
import { createContext } from "react";
|
||||||
|
|
||||||
interface SiteContextType {
|
interface SiteContextType {
|
||||||
site: GetSiteResponse | null;
|
site: GetSiteResponse;
|
||||||
updateSite: (updatedSite: Partial<GetSiteResponse>) => void;
|
updateSite: (updatedSite: Partial<GetSiteResponse>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,14 +6,14 @@ import { useState } from "react";
|
||||||
|
|
||||||
interface SiteProviderProps {
|
interface SiteProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
site: GetSiteResponse | null;
|
site: GetSiteResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SiteProvider({
|
export function SiteProvider({
|
||||||
children,
|
children,
|
||||||
site: serverSite,
|
site: serverSite
|
||||||
}: SiteProviderProps) {
|
}: SiteProviderProps) {
|
||||||
const [site, setSite] = useState<GetSiteResponse | null>(serverSite);
|
const [site, setSite] = useState<GetSiteResponse>(serverSite);
|
||||||
|
|
||||||
const updateSite = (updatedSite: Partial<GetSiteResponse>) => {
|
const updateSite = (updatedSite: Partial<GetSiteResponse>) => {
|
||||||
if (!site) {
|
if (!site) {
|
||||||
|
@ -25,7 +25,7 @@ export function SiteProvider({
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
...updatedSite,
|
...updatedSite
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue