This commit is contained in:
Owen Schwartz
2024-12-01 19:46:01 -05:00
57 changed files with 957 additions and 485 deletions

View File

@@ -1,27 +1,25 @@
app: app:
base_url: http://localhost:3000 base_url: https://fossorial.io
log_level: debug log_level: debug
save_logs: false save_logs: false
server: server:
external_port: 3000 external_port: 3000
internal_port: 3001 internal_port: 3001
internal_hostname: localhost internal_hostname: pangolin
secure_cookies: false secure_cookies: false
session_cookie_name: session session_cookie_name: session
resource_session_cookie_name: resource_session
traefik: traefik:
cert_resolver: letsencrypt cert_resolver: letsencrypt
http_entrypoint: web http_entrypoint: web
https_entrypoint: websecure https_entrypoint: websecure
prefer_wildcard_cert: true
badger:
session_query_parameter: __pang_sess
resource_session_cookie_name: resource_session
gerbil: gerbil:
start_port: 51820 start_port: 51820
base_endpoint: 127.0.0.1 base_endpoint: fossorial.io
use_subdomain: false use_subdomain: false
block_size: 16 block_size: 16
subnet_group: 10.0.0.0/8 subnet_group: 10.0.0.0/8

View File

@@ -4,13 +4,13 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "ENVIRONMENT=dev tsx watch server/index.ts", "dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:push": "npx tsx server/db/migrate.ts", "db:push": "npx tsx server/db/migrate.ts",
"db:hydrate": "npx tsx scripts/hydrate.ts", "db:hydrate": "npx tsx scripts/hydrate.ts",
"db:studio": "drizzle-kit studio", "db:studio": "drizzle-kit studio",
"build": "mkdir -p dist && next build && node scripts/esbuild.mjs -e server/index.ts -o dist/server.mjs", "build": "mkdir -p dist && next build && node scripts/esbuild.mjs -e server/index.ts -o dist/server.mjs",
"start": "ENVIRONMENT=prod node dist/server.mjs", "start": "NODE_ENV=development ENVIRONMENT=prod node dist/server.mjs",
"email": "email dev --dir server/emails/templates --port 3002" "email": "email dev --dir server/emails/templates --port 3002"
}, },
"dependencies": { "dependencies": {

View File

@@ -3,9 +3,13 @@ import { sha256 } from "@oslojs/crypto/sha2";
import { resourceSessions, ResourceSession } from "@server/db/schema"; import { resourceSessions, ResourceSession } from "@server/db/schema";
import db from "@server/db"; import db from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import config from "@server/config";
export const SESSION_COOKIE_NAME = "resource_session"; export const SESSION_COOKIE_NAME = "resource_session";
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30; export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
export const SECURE_COOKIES = config.server.secure_cookies;
export const COOKIE_DOMAIN =
"." + new URL(config.app.base_url).hostname.split(".").slice(-2).join(".");
export async function createResourceSession(opts: { export async function createResourceSession(opts: {
token: string; token: string;
@@ -115,25 +119,25 @@ export async function invalidateAllSessions(
} }
export function serializeResourceSessionCookie( export function serializeResourceSessionCookie(
cookieName: string,
token: string, token: string,
fqdn: string, fqdn: string,
secure: boolean,
): string { ): string {
if (secure) { if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=.localhost`; return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else { } else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=.localhost`; return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
} }
} }
export function createBlankResourceSessionTokenCookie( export function createBlankResourceSessionTokenCookie(
cookieName: string,
fqdn: string, fqdn: string,
secure: boolean,
): string { ): string {
if (secure) { if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${fqdn}`; return `${cookieName}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else { } else {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Domain=${fqdn}`; return `${cookieName}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
} }
} }

View File

@@ -25,9 +25,6 @@ const environmentSchema = z.object({
secure_cookies: z.boolean(), secure_cookies: z.boolean(),
signup_secret: z.string().optional(), signup_secret: z.string().optional(),
session_cookie_name: z.string(), session_cookie_name: z.string(),
}),
badger: z.object({
session_query_parameter: z.string(),
resource_session_cookie_name: z.string(), resource_session_cookie_name: z.string(),
}), }),
traefik: z.object({ traefik: z.object({
@@ -128,12 +125,14 @@ if (!parsedConfig.success) {
process.env.SERVER_EXTERNAL_PORT = process.env.SERVER_EXTERNAL_PORT =
parsedConfig.data.server.external_port.toString(); parsedConfig.data.server.external_port.toString();
process.env.SERVER_INTERNAL_PORT =
parsedConfig.data.server.internal_port.toString();
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED = parsedConfig.data.flags process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED = parsedConfig.data.flags
?.require_email_verification ?.require_email_verification
? "true" ? "true"
: "false"; : "false";
process.env.SESSION_COOKIE_NAME = parsedConfig.data.server.session_cookie_name; process.env.SESSION_COOKIE_NAME = parsedConfig.data.server.session_cookie_name;
process.env.RESOURCE_SESSION_QUERY_PARAM_NAME = process.env.RESOURCE_SESSION_COOKIE_NAME =
parsedConfig.data.badger.session_query_parameter; parsedConfig.data.server.resource_session_cookie_name;
export default parsedConfig.data; export default parsedConfig.data;

View File

@@ -287,16 +287,28 @@ export const resourceSessions = sqliteTable("resourceSessions", {
() => resourcePassword.passwordId, () => resourcePassword.passwordId,
{ {
onDelete: "cascade", onDelete: "cascade",
} },
), ),
pincodeId: integer("pincodeId").references( pincodeId: integer("pincodeId").references(
() => resourcePincode.pincodeId, () => resourcePincode.pincodeId,
{ {
onDelete: "cascade", onDelete: "cascade",
} },
), ),
}); });
export const resourceOtp = sqliteTable("resourceOtp", {
otpId: integer("otpId").primaryKey({
autoIncrement: true,
}),
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
email: text("email").notNull(),
otpHash: text("otpHash").notNull(),
expiresAt: integer("expiresAt").notNull(),
});
export type Org = InferSelectModel<typeof orgs>; export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>; export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>; export type Site = InferSelectModel<typeof sites>;
@@ -325,3 +337,4 @@ export type UserOrg = InferSelectModel<typeof userOrgs>;
export type ResourceSession = InferSelectModel<typeof resourceSessions>; export type ResourceSession = InferSelectModel<typeof resourceSessions>;
export type ResourcePincode = InferSelectModel<typeof resourcePincode>; export type ResourcePincode = InferSelectModel<typeof resourcePincode>;
export type ResourcePassword = InferSelectModel<typeof resourcePassword>; export type ResourcePassword = InferSelectModel<typeof resourcePassword>;
export type ResourceOtp = InferSelectModel<typeof resourceOtp>;

View File

@@ -1,4 +1,4 @@
import { render } from "@react-email/components"; import { render } from "@react-email/render";
import { ReactElement } from "react"; import { ReactElement } from "react";
import emailClient from "@server/emails"; import emailClient from "@server/emails";
import logger from "@server/logger"; import logger from "@server/logger";

View File

@@ -33,9 +33,17 @@ export const SendInviteLink = ({
<Html> <Html>
<Head /> <Head />
<Preview>{previewText}</Preview> <Preview>{previewText}</Preview>
<Tailwind> <Tailwind config={{
theme: {
extend: {
colors: {
primary: "#F97317"
}
}
}
}}>
<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"> <Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
<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 invited to join a Fossorial organization
</Heading> </Heading>
@@ -58,7 +66,7 @@ export const SendInviteLink = ({
<Section className="text-center my-6"> <Section className="text-center my-6">
<Button <Button
href={inviteLink} href={inviteLink}
className="rounded-md bg-gray-600 px-[12px] py-[12px] text-center font-semibold text-white cursor-pointer" className="rounded-lg bg-primary px-[12px] py-[9px] text-center font-semibold text-white cursor-pointer"
> >
Accept invitation to {orgName} Accept invitation to {orgName}
</Button> </Button>

View File

@@ -14,11 +14,13 @@ import * as React from "react";
interface VerifyEmailProps { interface VerifyEmailProps {
username?: string; username?: string;
verificationCode: string; verificationCode: string;
verifyLink: string;
} }
export const VerifyEmail = ({ export const VerifyEmail = ({
username, username,
verificationCode, verificationCode,
verifyLink,
}: VerifyEmailProps) => { }: VerifyEmailProps) => {
const previewText = `Verify your email, ${username}`; const previewText = `Verify your email, ${username}`;
@@ -26,21 +28,34 @@ export const VerifyEmail = ({
<Html> <Html>
<Head /> <Head />
<Preview>{previewText}</Preview> <Preview>{previewText}</Preview>
<Tailwind> <Tailwind
config={{
theme: {
extend: {
colors: {
primary: "#F97317",
},
},
},
}}
>
<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"> <Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
<Heading className="text-2xl font-semibold text-gray-800 text-center"> <Heading className="text-2xl font-semibold text-gray-800 text-center">
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">
Youve requested to verify your email. Please use Youve requested to verify your email. Please{" "}
the verification code below: <a href={verifyLink} className="text-primary">
click here
</a>{" "}
to verify your email, then enter the following code:
</Text> </Text>
<Section className="text-center my-6"> <Section className="text-center my-6">
<Text className="inline-block bg-gray-100 text-xl font-bold text-gray-900 py-2 px-4 border border-gray-300 rounded-md"> <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>
</Section> </Section>
@@ -59,3 +74,5 @@ export const VerifyEmail = ({
</Html> </Html>
); );
}; };
export default VerifyEmail;

View File

@@ -51,7 +51,7 @@ app.prepare().then(() => {
externalServer.use(logIncomingMiddleware); externalServer.use(logIncomingMiddleware);
externalServer.use(prefix, unauthenticated); externalServer.use(prefix, unauthenticated);
externalServer.use(prefix, authenticated); externalServer.use(prefix, authenticated);
externalServer.use(`${prefix}/ws`, wsRouter); // externalServer.use(`${prefix}/ws`, wsRouter);
externalServer.use(notFoundMiddleware); externalServer.use(notFoundMiddleware);
@@ -68,7 +68,7 @@ app.prepare().then(() => {
); );
}); });
handleWSUpgrade(httpServer); // handleWSUpgrade(httpServer);
externalServer.use(errorHandlerMiddleware); externalServer.use(errorHandlerMiddleware);

View File

@@ -0,0 +1,65 @@
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import HttpCode from "@server/types/HttpCode";
import { response } from "@server/utils";
import { validateSessionToken } from "@server/auth";
import { validateResourceSessionToken } from "@server/auth/resource";
export const params = z.object({
token: z.string(),
resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
});
export type CheckResourceSessionParams = z.infer<typeof params>;
export type CheckResourceSessionResponse = {
valid: boolean;
};
export async function checkResourceSession(
req: Request,
res: Response,
next: NextFunction,
): Promise<any> {
const parsedParams = params.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString(),
),
);
}
const { token, resourceId } = parsedParams.data;
try {
const { resourceSession } = await validateResourceSessionToken(
token,
resourceId,
);
let valid = false;
if (resourceSession) {
valid = true;
}
return response<CheckResourceSessionResponse>(res, {
data: { valid },
success: true,
error: false,
message: "Checked validity",
status: HttpCode.OK,
});
} catch (e) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to reset password",
),
);
}
}

View File

@@ -9,3 +9,4 @@ export * from "./requestEmailVerificationCode";
export * from "./changePassword"; export * from "./changePassword";
export * from "./requestPasswordReset"; export * from "./requestPasswordReset";
export * from "./resetPassword"; export * from "./resetPassword";
export * from "./checkResourceSession";

View File

@@ -139,7 +139,7 @@ export async function login(
success: true, success: true,
error: false, error: false,
message: "Email verification code sent", message: "Email verification code sent",
status: HttpCode.ACCEPTED, status: HttpCode.OK,
}); });
} }

View File

@@ -13,11 +13,18 @@ export async function sendEmailVerificationCode(
): Promise<void> { ): Promise<void> {
const code = await generateEmailVerificationCode(userId, email); const code = await generateEmailVerificationCode(userId, email);
await sendEmail(VerifyEmail({ username: email, verificationCode: code }), { await sendEmail(
to: email, VerifyEmail({
from: config.email?.no_reply, username: email,
subject: "Verify your email address", verificationCode: code,
}); verifyLink: `${config.app.base_url}/auth/verify-email`,
}),
{
to: email,
from: config.email?.no_reply,
subject: "Verify your email address",
},
);
} }
async function generateEmailVerificationCode( async function generateEmailVerificationCode(

View File

@@ -10,6 +10,7 @@ import {
resourcePassword, resourcePassword,
resourcePincode, resourcePincode,
resources, resources,
User,
userOrgs, userOrgs,
} from "@server/db/schema"; } from "@server/db/schema";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
@@ -19,10 +20,7 @@ import { Resource, roleResources, userResources } from "@server/db/schema";
import logger from "@server/logger"; import logger from "@server/logger";
const verifyResourceSessionSchema = z.object({ const verifyResourceSessionSchema = z.object({
sessions: z.object({ sessions: z.record(z.string()).optional(),
session: z.string().nullable(),
resource_session: z.string().nullable(),
}),
originalRequestURL: z.string().url(), originalRequestURL: z.string().url(),
scheme: z.string(), scheme: z.string(),
host: z.string(), host: z.string(),
@@ -98,13 +96,18 @@ export async function verifyResourceSession(
const redirectUrl = `${config.app.base_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`; const redirectUrl = `${config.app.base_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`;
if (sso && sessions.session) { if (!sessions) {
const { session, user } = await validateSessionToken( return notAllowed(res);
sessions.session, }
);
const sessionToken = sessions[config.server.session_cookie_name];
// check for unified login
if (sso && sessionToken) {
const { session, user } = await validateSessionToken(sessionToken);
if (session && user) { if (session && user) {
const isAllowed = await isUserAllowedToAccessResource( const isAllowed = await isUserAllowedToAccessResource(
user.userId, user,
resource, resource,
); );
@@ -117,11 +120,17 @@ export async function verifyResourceSession(
} }
} }
if (password && sessions.resource_session) { const resourceSessionToken =
sessions[
`${config.server.resource_session_cookie_name}_${resource.resourceId}`
];
if ((pincode || password) && resourceSessionToken) {
const { resourceSession } = await validateResourceSessionToken( const { resourceSession } = await validateResourceSessionToken(
sessions.resource_session, resourceSessionToken,
resource.resourceId, resource.resourceId,
); );
if (resourceSession) { if (resourceSession) {
if ( if (
pincode && pincode &&
@@ -165,7 +174,7 @@ function notAllowed(res: Response, redirectUrl?: string) {
error: false, error: false,
message: "Access denied", message: "Access denied",
status: HttpCode.OK, status: HttpCode.OK,
} };
logger.debug(JSON.stringify(data)); logger.debug(JSON.stringify(data));
return response<VerifyUserResponse>(res, data); return response<VerifyUserResponse>(res, data);
} }
@@ -177,21 +186,25 @@ function allowed(res: Response) {
error: false, error: false,
message: "Access allowed", message: "Access allowed",
status: HttpCode.OK, status: HttpCode.OK,
} };
logger.debug(JSON.stringify(data)); logger.debug(JSON.stringify(data));
return response<VerifyUserResponse>(res, data); return response<VerifyUserResponse>(res, data);
} }
async function isUserAllowedToAccessResource( async function isUserAllowedToAccessResource(
userId: string, user: User,
resource: Resource, resource: Resource,
) { ): Promise<boolean> {
if (config.flags?.require_email_verification && !user.emailVerified) {
return false;
}
const userOrgRole = await db const userOrgRole = await db
.select() .select()
.from(userOrgs) .from(userOrgs)
.where( .where(
and( and(
eq(userOrgs.userId, userId), eq(userOrgs.userId, user.userId),
eq(userOrgs.orgId, resource.orgId), eq(userOrgs.orgId, resource.orgId),
), ),
) )
@@ -221,7 +234,7 @@ async function isUserAllowedToAccessResource(
.from(userResources) .from(userResources)
.where( .where(
and( and(
eq(userResources.userId, userId), eq(userResources.userId, user.userId),
eq(userResources.resourceId, resource.resourceId), eq(userResources.resourceId, resource.resourceId),
), ),
) )

View File

@@ -2,6 +2,7 @@ import { Router } from "express";
import * as gerbil from "@server/routers/gerbil"; import * as gerbil from "@server/routers/gerbil";
import * as badger from "@server/routers/badger"; import * as badger from "@server/routers/badger";
import * as traefik from "@server/routers/traefik"; import * as traefik from "@server/routers/traefik";
import * as auth from "@server/routers/auth";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
// Root routes // Root routes
@@ -12,6 +13,10 @@ internalRouter.get("/", (_, res) => {
}); });
internalRouter.get("/traefik-config", traefik.traefikConfigProvider); internalRouter.get("/traefik-config", traefik.traefikConfigProvider);
internalRouter.get(
"/resource-session/:resourceId/:token",
auth.checkResourceSession,
);
// Gerbil routes // Gerbil routes
const gerbilRouter = Router(); const gerbilRouter = Router();

View File

@@ -14,6 +14,7 @@ import {
serializeResourceSessionCookie, serializeResourceSessionCookie,
} from "@server/auth/resource"; } from "@server/auth/resource";
import logger from "@server/logger"; import logger from "@server/logger";
import config from "@server/config";
export const authWithPasswordBodySchema = z.object({ export const authWithPasswordBodySchema = z.object({
password: z.string(), password: z.string(),
@@ -131,15 +132,15 @@ export async function authWithPassword(
token, token,
passwordId: definedPassword.passwordId, passwordId: definedPassword.passwordId,
}); });
// const secureCookie = resource.ssl; const cookieName = `${config.server.resource_session_cookie_name}_${resource.resourceId}`;
// const cookie = serializeResourceSessionCookie( const cookie = serializeResourceSessionCookie(
// token, cookieName,
// resource.fullDomain, token,
// secureCookie, resource.fullDomain,
// ); );
// res.appendHeader("Set-Cookie", cookie); res.appendHeader("Set-Cookie", cookie);
// logger.debug(cookie); // remove after testing logger.debug(cookie); // remove after testing
return response<AuthWithPasswordResponse>(res, { return response<AuthWithPasswordResponse>(res, {
data: { data: {

View File

@@ -14,6 +14,7 @@ import {
serializeResourceSessionCookie, serializeResourceSessionCookie,
} from "@server/auth/resource"; } from "@server/auth/resource";
import logger from "@server/logger"; import logger from "@server/logger";
import config from "@server/config";
export const authWithPincodeBodySchema = z.object({ export const authWithPincodeBodySchema = z.object({
pincode: z.string(), pincode: z.string(),
@@ -127,15 +128,15 @@ export async function authWithPincode(
token, token,
pincodeId: definedPincode.pincodeId, pincodeId: definedPincode.pincodeId,
}); });
// const secureCookie = resource.ssl; const cookieName = `${config.server.resource_session_cookie_name}_${resource.resourceId}`;
// const cookie = serializeResourceSessionCookie( const cookie = serializeResourceSessionCookie(
// token, cookieName,
// resource.fullDomain, token,
// secureCookie, resource.fullDomain,
// ); );
// res.appendHeader("Set-Cookie", cookie); res.appendHeader("Set-Cookie", cookie);
// logger.debug(cookie); // remove after testing logger.debug(cookie); // remove after testing
return response<AuthWithPincodeResponse>(res, { return response<AuthWithPincodeResponse>(res, {
data: { data: {

View File

@@ -94,6 +94,7 @@ export async function createResource(
orgId, orgId,
name, name,
subdomain, subdomain,
ssl: true,
}) })
.returning(); .returning();

View File

@@ -6,6 +6,8 @@ import {
sites, sites,
userResources, userResources,
roleResources, roleResources,
resourcePassword,
resourcePincode,
} from "@server/db/schema"; } from "@server/db/schema";
import response from "@server/utils/response"; import response from "@server/utils/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -46,39 +48,65 @@ const listResourcesSchema = z.object({
function queryResources( function queryResources(
accessibleResourceIds: number[], accessibleResourceIds: number[],
siteId?: number, siteId?: number,
orgId?: string orgId?: string,
) { ) {
if (siteId) { if (siteId) {
return db return db
.select({ .select({
resourceId: resources.resourceId, resourceId: resources.resourceId,
name: resources.name, name: resources.name,
subdomain: resources.subdomain, fullDomain: resources.fullDomain,
ssl: resources.ssl,
siteName: sites.name, siteName: sites.name,
siteId: sites.niceId,
passwordId: resourcePassword.passwordId,
pincodeId: resourcePincode.pincodeId,
sso: resources.sso,
}) })
.from(resources) .from(resources)
.leftJoin(sites, eq(resources.siteId, sites.siteId)) .leftJoin(sites, eq(resources.siteId, sites.siteId))
.leftJoin(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId),
)
.leftJoin(
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId),
)
.where( .where(
and( and(
inArray(resources.resourceId, accessibleResourceIds), inArray(resources.resourceId, accessibleResourceIds),
eq(resources.siteId, siteId) eq(resources.siteId, siteId),
) ),
); );
} else if (orgId) { } else if (orgId) {
return db return db
.select({ .select({
resourceId: resources.resourceId, resourceId: resources.resourceId,
name: resources.name, name: resources.name,
subdomain: resources.subdomain, ssl: resources.ssl,
fullDomain: resources.fullDomain,
siteName: sites.name, siteName: sites.name,
siteId: sites.niceId,
passwordId: resourcePassword.passwordId,
sso: resources.sso,
pincodeId: resourcePincode.pincodeId,
}) })
.from(resources) .from(resources)
.leftJoin(sites, eq(resources.siteId, sites.siteId)) .leftJoin(sites, eq(resources.siteId, sites.siteId))
.leftJoin(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId),
)
.leftJoin(
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId),
)
.where( .where(
and( and(
inArray(resources.resourceId, accessibleResourceIds), inArray(resources.resourceId, accessibleResourceIds),
eq(resources.orgId, orgId) eq(resources.orgId, orgId),
) ),
); );
} }
} }
@@ -91,7 +119,7 @@ export type ListResourcesResponse = {
export async function listResources( export async function listResources(
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction next: NextFunction,
): Promise<any> { ): Promise<any> {
try { try {
const parsedQuery = listResourcesSchema.safeParse(req.query); const parsedQuery = listResourcesSchema.safeParse(req.query);
@@ -99,8 +127,8 @@ export async function listResources(
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
parsedQuery.error.errors.map((e) => e.message).join(", ") parsedQuery.error.errors.map((e) => e.message).join(", "),
) ),
); );
} }
const { limit, offset } = parsedQuery.data; const { limit, offset } = parsedQuery.data;
@@ -110,8 +138,8 @@ export async function listResources(
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
parsedParams.error.errors.map((e) => e.message).join(", ") parsedParams.error.errors.map((e) => e.message).join(", "),
) ),
); );
} }
const { siteId, orgId } = parsedParams.data; const { siteId, orgId } = parsedParams.data;
@@ -120,8 +148,8 @@ export async function listResources(
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"User does not have access to this organization" "User does not have access to this organization",
) ),
); );
} }
@@ -132,17 +160,17 @@ export async function listResources(
.from(userResources) .from(userResources)
.fullJoin( .fullJoin(
roleResources, roleResources,
eq(userResources.resourceId, roleResources.resourceId) eq(userResources.resourceId, roleResources.resourceId),
) )
.where( .where(
or( or(
eq(userResources.userId, req.user!.userId), eq(userResources.userId, req.user!.userId),
eq(roleResources.roleId, req.userOrgRoleId!) eq(roleResources.roleId, req.userOrgRoleId!),
) ),
); );
const accessibleResourceIds = accessibleResources.map( const accessibleResourceIds = accessibleResources.map(
(resource) => resource.resourceId (resource) => resource.resourceId,
); );
let countQuery: any = db let countQuery: any = db
@@ -173,7 +201,10 @@ export async function listResources(
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
return next( return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred",
),
); );
} }
} }

View File

@@ -38,14 +38,15 @@ function querySites(orgId: string, accessibleSiteIds: number[]) {
megabytesIn: sites.megabytesIn, megabytesIn: sites.megabytesIn,
megabytesOut: sites.megabytesOut, megabytesOut: sites.megabytesOut,
orgName: orgs.name, orgName: orgs.name,
type: sites.type,
}) })
.from(sites) .from(sites)
.leftJoin(orgs, eq(sites.orgId, orgs.orgId)) .leftJoin(orgs, eq(sites.orgId, orgs.orgId))
.where( .where(
and( and(
inArray(sites.siteId, accessibleSiteIds), inArray(sites.siteId, accessibleSiteIds),
eq(sites.orgId, orgId) eq(sites.orgId, orgId),
) ),
); );
} }
@@ -57,7 +58,7 @@ export type ListSitesResponse = {
export async function listSites( export async function listSites(
req: Request, req: Request,
res: Response, res: Response,
next: NextFunction next: NextFunction,
): Promise<any> { ): Promise<any> {
try { try {
const parsedQuery = listSitesSchema.safeParse(req.query); const parsedQuery = listSitesSchema.safeParse(req.query);
@@ -65,8 +66,8 @@ export async function listSites(
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
fromError(parsedQuery.error) fromError(parsedQuery.error),
) ),
); );
} }
const { limit, offset } = parsedQuery.data; const { limit, offset } = parsedQuery.data;
@@ -76,8 +77,8 @@ export async function listSites(
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
fromError(parsedParams.error) fromError(parsedParams.error),
) ),
); );
} }
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
@@ -86,8 +87,8 @@ export async function listSites(
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"User does not have access to this organization" "User does not have access to this organization",
) ),
); );
} }
@@ -100,8 +101,8 @@ export async function listSites(
.where( .where(
or( or(
eq(userSites.userId, req.user!.userId), eq(userSites.userId, req.user!.userId),
eq(roleSites.roleId, req.userOrgRoleId!) eq(roleSites.roleId, req.userOrgRoleId!),
) ),
); );
const accessibleSiteIds = accessibleSites.map((site) => site.siteId); const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
@@ -113,8 +114,8 @@ export async function listSites(
.where( .where(
and( and(
inArray(sites.siteId, accessibleSiteIds), inArray(sites.siteId, accessibleSiteIds),
eq(sites.orgId, orgId) eq(sites.orgId, orgId),
) ),
); );
const sitesList = await baseQuery.limit(limit).offset(offset); const sitesList = await baseQuery.limit(limit).offset(offset);
@@ -137,7 +138,10 @@ export async function listSites(
}); });
} catch (error) { } catch (error) {
return next( return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred",
),
); );
} }
} }

View File

@@ -22,6 +22,10 @@ export async function traefikConfigProvider(
schema.orgs, schema.orgs,
eq(schema.resources.orgId, schema.orgs.orgId), eq(schema.resources.orgId, schema.orgs.orgId),
) )
.innerJoin(
schema.sites,
eq(schema.sites.siteId, schema.resources.siteId),
)
.where( .where(
and( and(
eq(schema.targets.enabled, true), eq(schema.targets.enabled, true),
@@ -51,11 +55,9 @@ export async function traefikConfigProvider(
`http://${config.server.internal_hostname}:${config.server.internal_port}`, `http://${config.server.internal_hostname}:${config.server.internal_port}`,
).href, ).href,
resourceSessionCookieName: resourceSessionCookieName:
config.badger.resource_session_cookie_name, config.server.resource_session_cookie_name,
userSessionCookieName: userSessionCookieName:
config.server.session_cookie_name, config.server.session_cookie_name,
sessionQueryParameter:
config.badger.session_query_parameter,
}, },
}, },
}, },
@@ -70,6 +72,7 @@ export async function traefikConfigProvider(
for (const item of all) { for (const item of all) {
const target = item.targets; const target = item.targets;
const resource = item.resources; const resource = item.resources;
const site = item.sites;
const org = item.orgs; const org = item.orgs;
const routerName = `${target.targetId}-router`; const routerName = `${target.targetId}-router`;
@@ -112,7 +115,7 @@ export async function traefikConfigProvider(
? config.traefik.https_entrypoint ? config.traefik.https_entrypoint
: config.traefik.http_entrypoint, : config.traefik.http_entrypoint,
], ],
middlewares: resource.ssl ? [badgerMiddlewareName] : [], middlewares: [badgerMiddlewareName],
service: serviceName, service: serviceName,
rule: `Host(\`${fullDomain}\`)`, rule: `Host(\`${fullDomain}\`)`,
...(resource.ssl ? { tls } : {}), ...(resource.ssl ? { tls } : {}),
@@ -128,15 +131,28 @@ export async function traefikConfigProvider(
}; };
} }
http.services![serviceName] = { if (site.type === "newt") {
loadBalancer: { const ip = site.subnet.split("/")[0];
servers: [ http.services![serviceName] = {
{ loadBalancer: {
url: `${target.method}://${target.ip}:${target.port}`, servers: [
}, {
], url: `${target.method}://${ip}:${target.internalPort}`,
}, },
}; ],
},
};
} else if (site.type === "wireguard") {
http.services![serviceName] = {
loadBalancer: {
servers: [
{
url: `${target.method}://${target.ip}:${target.port}`,
},
],
},
};
}
} }
return res.status(HttpCode.OK).json({ http }); return res.status(HttpCode.OK).json({ http });

View File

@@ -86,7 +86,6 @@ export async function inviteUser(
inviteTracker[email].timestamps.push(currentTime); inviteTracker[email].timestamps.push(currentTime);
logger.debug("here0")
const org = await db const org = await db
.select() .select()
.from(orgs) .from(orgs)
@@ -98,7 +97,6 @@ export async function inviteUser(
); );
} }
logger.debug("here1")
const existingUser = await db const existingUser = await db
.select() .select()
.from(users) .from(users)
@@ -114,7 +112,6 @@ export async function inviteUser(
); );
} }
logger.debug("here2")
const inviteId = generateRandomString( const inviteId = generateRandomString(
10, 10,
alphabet("a-z", "A-Z", "0-9"), alphabet("a-z", "A-Z", "0-9"),
@@ -124,7 +121,6 @@ export async function inviteUser(
const tokenHash = await hashPassword(token); const tokenHash = await hashPassword(token);
logger.debug("here3")
// delete any existing invites for this email // delete any existing invites for this email
await db await db
.delete(userInvites) .delete(userInvites)
@@ -133,7 +129,6 @@ export async function inviteUser(
) )
.execute(); .execute();
logger.debug("here4")
await db.insert(userInvites).values({ await db.insert(userInvites).values({
inviteId, inviteId,
orgId, orgId,
@@ -145,23 +140,21 @@ export async function inviteUser(
const inviteLink = `${config.app.base_url}/invite?token=${inviteId}-${token}`; const inviteLink = `${config.app.base_url}/invite?token=${inviteId}-${token}`;
logger.debug("here5") await sendEmail(
// await sendEmail( SendInviteLink({
// SendInviteLink({ email,
// email, inviteLink,
// inviteLink, expiresInDays: (validHours / 24).toString(),
// expiresInDays: (validHours / 24).toString(), orgName: org[0].name || orgId,
// orgName: org[0].name || orgId, inviterName: req.user?.email,
// inviterName: req.user?.email, }),
// }), {
// { to: email,
// to: email, from: config.email?.no_reply,
// from: config.email?.no_reply, subject: "You're invited to join a Fossorial organization",
// subject: "You're invited to join a Fossorial organization", },
// }, );
// );
logger.debug("here6")
return response<InviteUserResponse>(res, { return response<InviteUserResponse>(res, {
data: { data: {
inviteLink, inviteLink,

View File

@@ -22,4 +22,12 @@ export const internal = axios.create({
}, },
}); });
export const priv = axios.create({
baseURL: `http://localhost:${process.env.SERVER_INTERNAL_PORT}/api/v1`,
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
});
export default api; export default api;

View File

@@ -1,6 +1,8 @@
import { internal } from "@app/api"; import { internal } from "@app/api";
import { authCookieHeader } from "@app/api/cookies"; import { authCookieHeader } from "@app/api/cookies";
import { verifySession } from "@app/lib/auth/verifySession";
import { GetOrgResponse } from "@server/routers/org"; import { GetOrgResponse } from "@server/routers/org";
import { GetOrgUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cache } from "react"; import { cache } from "react";
@@ -17,6 +19,25 @@ export default async function OrgLayout(props: {
redirect(`/`); redirect(`/`);
} }
const getUser = cache(verifySession);
const user = await getUser();
if (!user) {
redirect(`/?redirect=/${orgId}`);
}
try {
const getOrgUser = cache(() =>
internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${orgId}/user/${user.userId}`,
cookie,
),
);
const orgUser = await getOrgUser();
} catch {
redirect(`/`);
}
try { try {
const getOrg = cache(() => const getOrg = cache(() =>
internal.get<AxiosResponse<GetOrgResponse>>( internal.get<AxiosResponse<GetOrgResponse>>(

View File

@@ -14,27 +14,6 @@ export default async function OrgPage(props: OrgPageProps) {
const params = await props.params; const params = await props.params;
const orgId = params.orgId; const orgId = params.orgId;
const getUser = cache(verifySession);
const user = await getUser();
if (!user) {
redirect("/auth/login");
}
const cookie = await authCookieHeader();
try {
const getOrgUser = cache(() =>
internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${orgId}/user/${user.userId}`,
cookie
)
);
const orgUser = await getOrgUser();
} catch {
redirect(`/`);
}
return ( return (
<> <>
<p>Welcome to {orgId} dashboard</p> <p>Welcome to {orgId} dashboard</p>

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { SidebarSettings } from "@app/components/SidebarSettings"; import { SidebarSettings } from "@app/components/SidebarSettings";
type AccessPageHeaderAndNavProps = { type AccessPageHeaderAndNavProps = {
@@ -22,16 +23,12 @@ export default function AccessPageHeaderAndNav({
return ( return (
<> <>
{" "} <SettingsSectionTitle
<div className="space-y-0.5 select-none mb-6"> title="Manage Users & Roles"
<h2 className="text-2xl font-bold tracking-tight"> description="Invite users and add them to roles to manage access to your
Users & Roles organization"
</h2> />
<p className="text-muted-foreground">
Invite users and add them to roles to manage access to your
organization
</p>
</div>
<SidebarSettings sidebarNavItems={sidebarNavItems}> <SidebarSettings sidebarNavItems={sidebarNavItems}>
{children} {children}
</SidebarSettings> </SidebarSettings>

View File

@@ -22,7 +22,7 @@ import {
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { useState } from "react"; import { useState } from "react";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { Plus } from "lucide-react"; import { Plus, Search } from "lucide-react";
import { DataTablePagination } from "@app/components/DataTablePagination"; import { DataTablePagination } from "@app/components/DataTablePagination";
interface DataTableProps<TData, TValue> { interface DataTableProps<TData, TValue> {
@@ -61,19 +61,23 @@ export function RolesDataTable<TData, TValue>({
return ( return (
<div> <div>
<div className="flex items-center justify-between pb-4"> <div className="flex items-center justify-between pb-4">
<Input <div className="flex items-center max-w-sm mr-2 w-full relative">
placeholder="Search roles" <Input
value={ placeholder="Search roles"
(table.getColumn("name")?.getFilterValue() as string) ?? value={
"" (table
} .getColumn("name")
onChange={(event) => ?.getFilterValue() as string) ?? ""
table }
.getColumn("name") onChange={(event) =>
?.setFilterValue(event.target.value) table
} .getColumn("name")
className="max-w-sm mr-2" ?.setFilterValue(event.target.value)
/> }
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2" />
</div>
<Button <Button
onClick={() => { onClick={() => {
if (addRole) { if (addRole) {
@@ -84,7 +88,7 @@ export function RolesDataTable<TData, TValue>({
<Plus className="mr-2 h-4 w-4" /> Add Role <Plus className="mr-2 h-4 w-4" /> Add Role
</Button> </Button>
</div> </div>
<div> <div className="border rounded-md">
<Table> <Table>
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
@@ -97,7 +101,7 @@ export function RolesDataTable<TData, TValue>({
: flexRender( : flexRender(
header.column.columnDef header.column.columnDef
.header, .header,
header.getContext() header.getContext(),
)} )}
</TableHead> </TableHead>
); );
@@ -118,7 +122,7 @@ export function RolesDataTable<TData, TValue>({
<TableCell key={cell.id}> <TableCell key={cell.id}>
{flexRender( {flexRender(
cell.column.columnDef.cell, cell.column.columnDef.cell,
cell.getContext() cell.getContext(),
)} )}
</TableCell> </TableCell>
))} ))}

View File

@@ -65,6 +65,14 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
return ( return (
<> <>
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
{roleRow.isAdmin && (
<Button
variant="ghost"
className="h-8 w-8 p-0 opacity-0 cursor-default"
>
Placeholder
</Button>
)}
{!roleRow.isAdmin && ( {!roleRow.isAdmin && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -81,7 +89,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem> <DropdownMenuItem>
<button <button
className="text-red-600 hover:text-red-800" className="text-red-500"
onClick={() => { onClick={() => {
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
setUserToRemove(roleRow); setUserToRemove(roleRow);
@@ -117,7 +125,9 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
roleToDelete={roleToRemove} roleToDelete={roleToRemove}
afterDelete={() => { afterDelete={() => {
setRoles((prev) => setRoles((prev) =>
prev.filter((r) => r.roleId !== roleToRemove.roleId) prev.filter(
(r) => r.roleId !== roleToRemove.roleId,
),
); );
setUserToRemove(null); setUserToRemove(null);
}} }}

View File

@@ -23,7 +23,7 @@ import { Button } from "@app/components/ui/button";
import { useState } from "react"; import { useState } from "react";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "../../../../../../components/DataTablePagination"; import { DataTablePagination } from "../../../../../../components/DataTablePagination";
import { Plus } from "lucide-react"; import { Plus, Search } from "lucide-react";
interface DataTableProps<TData, TValue> { interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]; columns: ColumnDef<TData, TValue>[];
@@ -61,20 +61,23 @@ export function UsersDataTable<TData, TValue>({
return ( return (
<div> <div>
<div className="flex items-center justify-between pb-4"> <div className="flex items-center justify-between pb-4">
<Input <div className="flex items-center max-w-sm mr-2 w-full relative">
placeholder="Search users" <Input
value={ placeholder="Search users"
(table value={
.getColumn("email") (table
?.getFilterValue() as string) ?? "" .getColumn("email")
} ?.getFilterValue() as string) ?? ""
onChange={(event) => }
table onChange={(event) =>
.getColumn("email") table
?.setFilterValue(event.target.value) .getColumn("email")
} ?.setFilterValue(event.target.value)
className="max-w-sm mr-2" }
/> className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2" />
</div>
<Button <Button
onClick={() => { onClick={() => {
if (inviteUser) { if (inviteUser) {
@@ -85,7 +88,7 @@ export function UsersDataTable<TData, TValue>({
<Plus className="mr-2 h-4 w-4" /> Invite User <Plus className="mr-2 h-4 w-4" /> Invite User
</Button> </Button>
</div> </div>
<div> <div className="border rounded-md">
<Table> <Table>
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
@@ -98,7 +101,7 @@ export function UsersDataTable<TData, TValue>({
: flexRender( : flexRender(
header.column.columnDef header.column.columnDef
.header, .header,
header.getContext() header.getContext(),
)} )}
</TableHead> </TableHead>
); );
@@ -119,7 +122,7 @@ export function UsersDataTable<TData, TValue>({
<TableCell key={cell.id}> <TableCell key={cell.id}>
{flexRender( {flexRender(
cell.column.columnDef.cell, cell.column.columnDef.cell,
cell.getContext() cell.getContext(),
)} )}
</TableCell> </TableCell>
))} ))}

View File

@@ -99,7 +99,9 @@ export default function UsersTable({ users: u }: UsersTableProps) {
return ( return (
<div className="flex flex-row items-center gap-1"> <div className="flex flex-row items-center gap-1">
{userRow.isOwner && <Crown className="w-4 h-4" />} {userRow.isOwner && (
<Crown className="w-4 h-4 text-yellow-600" />
)}
<span>{userRow.role}</span> <span>{userRow.role}</span>
</div> </div>
); );
@@ -113,6 +115,14 @@ export default function UsersTable({ users: u }: UsersTableProps) {
return ( return (
<> <>
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
{userRow.isOwner && (
<Button
variant="ghost"
className="opacity-0 cursor-default"
>
Placeholder
</Button>
)}
{!userRow.isOwner && ( {!userRow.isOwner && (
<> <>
<DropdownMenu> <DropdownMenu>
@@ -138,13 +148,13 @@ export default function UsersTable({ users: u }: UsersTableProps) {
{userRow.email !== user?.email && ( {userRow.email !== user?.email && (
<DropdownMenuItem> <DropdownMenuItem>
<button <button
className="text-red-600 hover:text-red-800" className="text-red-500"
onClick={() => { onClick={() => {
setIsDeleteModalOpen( setIsDeleteModalOpen(
true true,
); );
setSelectedUser( setSelectedUser(
userRow userRow,
); );
}} }}
> >
@@ -154,18 +164,17 @@ export default function UsersTable({ users: u }: UsersTableProps) {
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Button <Link
variant={"gray"} href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
className="ml-2"
onClick={() =>
router.push(
`/${org?.org.orgId}/settings/access/users/${userRow.id}`
)
}
> >
Manage{" "} <Button
<ArrowRight className="ml-2 w-4 h-4" /> variant={"gray"}
</Button> className="ml-2"
>
Manage
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</> </>
)} )}
</div> </div>
@@ -185,7 +194,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
title: "Failed to remove user", title: "Failed to remove user",
description: formatAxiosError( description: formatAxiosError(
e, e,
"An error occurred while removing the user." "An error occurred while removing the user.",
), ),
}); });
}); });
@@ -198,7 +207,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
}); });
setUsers((prev) => setUsers((prev) =>
prev.filter((u) => u.id !== selectedUser?.id) prev.filter((u) => u.id !== selectedUser?.id),
); );
} }
} }

View File

@@ -149,7 +149,7 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
size="lg" size="lg"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
className="w-full md:w-[200px] h-12 px-3 py-4 bg-neutral" className="w-full md:w-[200px] h-12 px-3 py-4 bg-neutral hover:bg-neutral"
> >
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<div className="flex flex-col items-start"> <div className="flex flex-col items-start">
@@ -202,29 +202,6 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{/* <Select
defaultValue={orgId}
onValueChange={(val) => {
router.push(`/${val}/settings`);
}}
>
<SelectTrigger className="w-[100px] md:w-[180px]">
<SelectValue placeholder="Select an org" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{orgs.map((org) => (
<SelectItem
value={org.name}
key={org.orgId}
>
{org.name}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select> */}
</div> </div>
</div> </div>
</> </>

View File

@@ -26,7 +26,7 @@ export default async function GeneralSettingsPage({
const user = await getUser(); const user = await getUser();
if (!user) { if (!user) {
redirect("/auth/login"); redirect(`/?redirect=/${orgId}/settings/general`);
} }
let orgUser = null; let orgUser = null;
@@ -34,8 +34,8 @@ export default async function GeneralSettingsPage({
const getOrgUser = cache(async () => const getOrgUser = cache(async () =>
internal.get<AxiosResponse<GetOrgUserResponse>>( internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${orgId}/user/${user.userId}`, `/org/${orgId}/user/${user.userId}`,
await authCookieHeader() await authCookieHeader(),
) ),
); );
const res = await getOrgUser(); const res = await getOrgUser();
orgUser = res.data.data; orgUser = res.data.data;
@@ -48,8 +48,8 @@ export default async function GeneralSettingsPage({
const getOrg = cache(async () => const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>( internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${orgId}`, `/org/${orgId}`,
await authCookieHeader() await authCookieHeader(),
) ),
); );
const res = await getOrg(); const res = await getOrg();
org = res.data.data; org = res.data.data;

View File

@@ -55,7 +55,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const user = await getUser(); const user = await getUser();
if (!user) { if (!user) {
redirect("/auth/login"); redirect(`/?redirect=/${params.orgId}/`);
} }
const cookie = await authCookieHeader(); const cookie = await authCookieHeader();
@@ -64,8 +64,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const getOrgUser = cache(() => const getOrgUser = cache(() =>
internal.get<AxiosResponse<GetOrgUserResponse>>( internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${params.orgId}/user/${user.userId}`, `/org/${params.orgId}/user/${user.userId}`,
cookie cookie,
) ),
); );
const orgUser = await getOrgUser(); const orgUser = await getOrgUser();
@@ -79,7 +79,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
let orgs: ListOrgsResponse["orgs"] = []; let orgs: ListOrgsResponse["orgs"] = [];
try { try {
const getOrgs = cache(() => const getOrgs = cache(() =>
internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie) internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie),
); );
const res = await getOrgs(); const res = await getOrgs();
if (res && res.data.data.orgs) { if (res && res.data.data.orgs) {
@@ -91,7 +91,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
return ( return (
<> <>
<div className="w-full border-b bg-neutral-100 dark:bg-neutral-900 mb-6 select-none sm:px-0 px-3 pt-3"> <div className="w-full border-b bg-neutral-100 dark:bg-neutral-800 mb-6 select-none sm:px-0 px-3 pt-3">
<div className="container mx-auto flex flex-col content-between gap-4 "> <div className="container mx-auto flex flex-col content-between gap-4 ">
<Header <Header
email={user.email} email={user.email}

View File

@@ -305,11 +305,11 @@ export default function ResourceAuthenticationPage() {
/> />
)} )}
<div className="space-y-12"> <div className="space-y-12 lg:max-w-2xl">
<section className="space-y-8"> <section className="space-y-8">
<SettingsSectionTitle <SettingsSectionTitle
title="Users & Roles" title="Users & Roles"
description="Configure which users can access this resource (only applicable if SSO enabled)" description="Configure which users and roles can visit this resource"
size="1xl" size="1xl"
/> />
@@ -320,11 +320,13 @@ export default function ResourceAuthenticationPage() {
defaultChecked={resource.sso} defaultChecked={resource.sso}
onCheckedChange={(val) => setSsoEnabled(val)} onCheckedChange={(val) => setSsoEnabled(val)}
/> />
<Label htmlFor="sso-toggle">Allow SSO</Label> <Label htmlFor="sso-toggle">
Allow Unified Login
</Label>
</div> </div>
<span className="text-muted-foreground text-sm"> <span className="text-muted-foreground text-sm">
Existing users will only have to login once for all Existing users will only have to login once for all
resources that have SSO enabled. resources that have this enabled.
</span> </span>
</div> </div>
@@ -460,7 +462,7 @@ export default function ResourceAuthenticationPage() {
<Separator /> <Separator />
<section className="space-y-8 lg:max-w-2xl"> <section className="space-y-8">
<SettingsSectionTitle <SettingsSectionTitle
title="Authentication Methods" title="Authentication Methods"
description="Allow anyone to access the resource via the below methods" description="Allow anyone to access the resource via the below methods"

View File

@@ -233,6 +233,24 @@ export default function ReverseProxyTargets(props: {
} }
const columns: ColumnDef<LocalTarget>[] = [ const columns: ColumnDef<LocalTarget>[] = [
{
accessorKey: "method",
header: "Method",
cell: ({ row }) => (
<Select
defaultValue={row.original.method}
onValueChange={(value) =>
updateTarget(row.original.targetId, { method: value })
}
>
<SelectTrigger>{row.original.method}</SelectTrigger>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
</SelectContent>
</Select>
),
},
{ {
accessorKey: "ip", accessorKey: "ip",
header: "IP Address", header: "IP Address",
@@ -262,24 +280,6 @@ export default function ReverseProxyTargets(props: {
/> />
), ),
}, },
{
accessorKey: "method",
header: "Method",
cell: ({ row }) => (
<Select
defaultValue={row.original.method}
onValueChange={(value) =>
updateTarget(row.original.targetId, { method: value })
}
>
<SelectTrigger>{row.original.method}</SelectTrigger>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
</SelectContent>
</Select>
),
},
// { // {
// accessorKey: "protocol", // accessorKey: "protocol",
// header: "Protocol", // header: "Protocol",
@@ -368,7 +368,7 @@ export default function ReverseProxyTargets(props: {
</div> </div>
</section> </section>
<hr /> <hr className="lg:max-w-2xl" />
<section className="space-y-8"> <section className="space-y-8">
<SettingsSectionTitle <SettingsSectionTitle
@@ -386,25 +386,6 @@ export default function ReverseProxyTargets(props: {
className="space-y-8" className="space-y-8"
> >
<div className="grid grid-cols-2 md:grid-cols-3 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<FormField
control={addTargetForm.control}
name="ip"
render={({ field }) => (
<FormItem>
<FormLabel>
IP Address
</FormLabel>
<FormControl>
<Input id="ip" {...field} />
</FormControl>
<FormDescription>
Enter the IP address of the
target.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={addTargetForm.control} control={addTargetForm.control}
name="method" name="method"
@@ -444,6 +425,25 @@ export default function ReverseProxyTargets(props: {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={addTargetForm.control}
name="ip"
render={({ field }) => (
<FormItem>
<FormLabel>
IP Address
</FormLabel>
<FormControl>
<Input id="ip" {...field} />
</FormControl>
<FormDescription>
Enter the IP address of the
target.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={addTargetForm.control} control={addTargetForm.control}
name="port" name="port"

View File

@@ -34,7 +34,7 @@ import { ListSitesResponse } from "@server/routers/site";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import api from "@app/api"; import api from "@app/api";
import { useParams } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { GetResourceAuthInfoResponse } from "@server/routers/resource"; import { GetResourceAuthInfoResponse } from "@server/routers/resource";
import { useToast } from "@app/hooks/useToast"; import { useToast } from "@app/hooks/useToast";
@@ -57,6 +57,7 @@ export default function GeneralForm() {
const { toast } = useToast(); const { toast } = useToast();
const { resource, updateResource } = useResourceContext(); const { resource, updateResource } = useResourceContext();
const { org } = useOrgContext(); const { org } = useOrgContext();
const router = useRouter();
const orgId = params.orgId; const orgId = params.orgId;
@@ -112,6 +113,8 @@ export default function GeneralForm() {
}); });
updateResource({ name: data.name, subdomain: data.subdomain }); updateResource({ name: data.name, subdomain: data.subdomain });
router.refresh();
}) })
.finally(() => setSaveLoading(false)); .finally(() => setSaveLoading(false));
} }

View File

@@ -59,11 +59,6 @@ const accountFormSchema = z.object({
type AccountFormValues = z.infer<typeof accountFormSchema>; type AccountFormValues = z.infer<typeof accountFormSchema>;
const defaultValues: Partial<AccountFormValues> = {
subdomain: "",
name: "My Resource",
};
type CreateResourceFormProps = { type CreateResourceFormProps = {
open: boolean; open: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
@@ -88,7 +83,10 @@ export default function CreateResourceForm({
const form = useForm<AccountFormValues>({ const form = useForm<AccountFormValues>({
resolver: zodResolver(accountFormSchema), resolver: zodResolver(accountFormSchema),
defaultValues, defaultValues: {
subdomain: "",
name: "My Resource",
},
}); });
useEffect(() => { useEffect(() => {
@@ -98,9 +96,13 @@ export default function CreateResourceForm({
const fetchSites = async () => { const fetchSites = async () => {
const res = await api.get<AxiosResponse<ListSitesResponse>>( const res = await api.get<AxiosResponse<ListSitesResponse>>(
`/org/${orgId}/sites/` `/org/${orgId}/sites/`,
); );
setSites(res.data.data.sites); setSites(res.data.data.sites);
if (res.data.data.sites.length > 0) {
form.setValue("siteId", res.data.data.sites[0].siteId);
}
}; };
fetchSites(); fetchSites();
@@ -116,7 +118,7 @@ export default function CreateResourceForm({
name: data.name, name: data.name,
subdomain: data.subdomain, subdomain: data.subdomain,
// subdomain: data.subdomain, // subdomain: data.subdomain,
} },
) )
.catch((e) => { .catch((e) => {
toast({ toast({
@@ -124,7 +126,7 @@ export default function CreateResourceForm({
title: "Error creating resource", title: "Error creating resource",
description: formatAxiosError( description: formatAxiosError(
e, e,
"An error occurred when creating the resource" "An error occurred when creating the resource",
), ),
}); });
}); });
@@ -196,7 +198,7 @@ export default function CreateResourceForm({
onChange={(value) => onChange={(value) =>
form.setValue( form.setValue(
"subdomain", "subdomain",
value value,
) )
} }
/> />
@@ -225,14 +227,14 @@ export default function CreateResourceForm({
className={cn( className={cn(
"w-[350px] justify-between", "w-[350px] justify-between",
!field.value && !field.value &&
"text-muted-foreground" "text-muted-foreground",
)} )}
> >
{field.value {field.value
? sites.find( ? sites.find(
(site) => (site) =>
site.siteId === site.siteId ===
field.value field.value,
)?.name )?.name
: "Select site"} : "Select site"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
@@ -259,7 +261,7 @@ export default function CreateResourceForm({
onSelect={() => { onSelect={() => {
form.setValue( form.setValue(
"siteId", "siteId",
site.siteId site.siteId,
); );
}} }}
> >
@@ -269,14 +271,14 @@ export default function CreateResourceForm({
site.siteId === site.siteId ===
field.value field.value
? "opacity-100" ? "opacity-100"
: "opacity-0" : "opacity-0",
)} )}
/> />
{ {
site.name site.name
} }
</CommandItem> </CommandItem>
) ),
)} )}
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>

View File

@@ -24,7 +24,7 @@ import { Button } from "@app/components/ui/button";
import { useState } from "react"; import { useState } from "react";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination"; import { DataTablePagination } from "@app/components/DataTablePagination";
import { Plus } from "lucide-react"; import { Plus, Search } from "lucide-react";
interface ResourcesDataTableProps<TData, TValue> { interface ResourcesDataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]; columns: ColumnDef<TData, TValue>[];
@@ -62,19 +62,23 @@ export function ResourcesDataTable<TData, TValue>({
return ( return (
<div> <div>
<div className="flex items-center justify-between pb-4"> <div className="flex items-center justify-between pb-4">
<Input <div className="flex items-center max-w-sm mr-2 w-full relative">
placeholder="Search resources" <Input
value={ placeholder="Search resources"
(table.getColumn("name")?.getFilterValue() as string) ?? value={
"" (table
} .getColumn("name")
onChange={(event) => ?.getFilterValue() as string) ?? ""
table }
.getColumn("name") onChange={(event) =>
?.setFilterValue(event.target.value) table
} .getColumn("name")
className="max-w-sm mr-2" ?.setFilterValue(event.target.value)
/> }
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2" />
</div>
<Button <Button
onClick={() => { onClick={() => {
if (addResource) { if (addResource) {
@@ -85,7 +89,7 @@ export function ResourcesDataTable<TData, TValue>({
<Plus className="mr-2 h-4 w-4" /> Add Resource <Plus className="mr-2 h-4 w-4" /> Add Resource
</Button> </Button>
</div> </div>
<div> <div className="border rounded-md">
<Table> <Table>
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
@@ -98,7 +102,7 @@ export function ResourcesDataTable<TData, TValue>({
: flexRender( : flexRender(
header.column.columnDef header.column.columnDef
.header, .header,
header.getContext() header.getContext(),
)} )}
</TableHead> </TableHead>
); );
@@ -119,7 +123,7 @@ export function ResourcesDataTable<TData, TValue>({
<TableCell key={cell.id}> <TableCell key={cell.id}>
{flexRender( {flexRender(
cell.column.columnDef.cell, cell.column.columnDef.cell,
cell.getContext() cell.getContext(),
)} )}
</TableCell> </TableCell>
))} ))}

View File

@@ -9,7 +9,16 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@app/components/ui/dropdown-menu"; } from "@app/components/ui/dropdown-menu";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; import {
Copy,
ArrowRight,
ArrowUpDown,
MoreHorizontal,
Check,
ArrowUpRight,
ShieldOff,
ShieldCheck,
} from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import api from "@app/api"; import api from "@app/api";
@@ -26,6 +35,8 @@ export type ResourceRow = {
orgId: string; orgId: string;
domain: string; domain: string;
site: string; site: string;
siteId: string;
hasAuth: boolean;
}; };
type ResourcesTableProps = { type ResourcesTableProps = {
@@ -91,10 +102,111 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
</Button> </Button>
); );
}, },
cell: ({ row }) => {
const resourceRow = row.original;
return (
<Button variant="outline">
<Link
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteId}`}
>
{resourceRow.site}
</Link>
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
);
},
}, },
{ {
accessorKey: "domain", accessorKey: "domain",
header: "Domain", header: "Full URL",
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div className="flex items-center">
<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>
);
},
},
{
accessorKey: "hasAuth",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Authentication
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const resourceRow = row.original;
return (
<div>
{resourceRow.hasAuth ? (
<span className="text-green-500 flex items-center space-x-2">
<ShieldCheck className="w-4 h-4" />
<span>Protected</span>
</span>
) : (
<span className="text-yellow-500 flex items-center space-x-2">
<ShieldOff className="w-4 h-4" />
<span>Not Protected</span>
</span>
)}
</div>
);
},
}, },
{ {
id: "actions", id: "actions",
@@ -130,28 +242,25 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
<button <button
onClick={() => { onClick={() => {
setSelectedResource( setSelectedResource(
resourceRow resourceRow,
); );
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
}} }}
className="text-red-600 hover:text-red-800 hover:underline cursor-pointer" className="text-red-500"
> >
Delete Delete
</button> </button>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Button <Link
variant={"gray"} href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
className="ml-2"
onClick={() =>
router.push(
`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`
)
}
> >
Edit <ArrowRight className="ml-2 w-4 h-4" /> <Button variant={"gray"} className="ml-2">
</Button> Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div> </div>
</> </>
); );

View File

@@ -19,7 +19,7 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
try { try {
const res = await internal.get<AxiosResponse<ListResourcesResponse>>( const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
`/org/${params.orgId}/resources`, `/org/${params.orgId}/resources`,
await authCookieHeader() await authCookieHeader(),
); );
resources = res.data.data.resources; resources = res.data.data.resources;
} catch (e) { } catch (e) {
@@ -31,8 +31,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
const getOrg = cache(async () => const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>( internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`, `/org/${params.orgId}`,
await authCookieHeader() await authCookieHeader(),
) ),
); );
const res = await getOrg(); const res = await getOrg();
org = res.data.data; org = res.data.data;
@@ -49,8 +49,13 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
id: resource.resourceId, id: resource.resourceId,
name: resource.name, name: resource.name,
orgId: params.orgId, orgId: params.orgId,
domain: resource.subdomain || "", domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
site: resource.siteName || "None", site: resource.siteName || "None",
siteId: resource.siteId || "Unknown",
hasAuth:
resource.sso ||
resource.pincodeId !== null ||
resource.pincodeId !== null,
}; };
}); });

View File

@@ -24,7 +24,7 @@ import { Button } from "@app/components/ui/button";
import { useState } from "react"; import { useState } from "react";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "../../../../../components/DataTablePagination"; import { DataTablePagination } from "../../../../../components/DataTablePagination";
import { Plus } from "lucide-react"; import { Plus, Search } from "lucide-react";
interface DataTableProps<TData, TValue> { interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]; columns: ColumnDef<TData, TValue>[];
@@ -62,19 +62,23 @@ export function SitesDataTable<TData, TValue>({
return ( return (
<div> <div>
<div className="flex items-center justify-between pb-4"> <div className="flex items-center justify-between pb-4">
<Input <div className="flex items-center max-w-sm mr-2 w-full relative">
placeholder="Search sites" <Input
value={ placeholder="Search sites"
(table.getColumn("name")?.getFilterValue() as string) ?? value={
"" (table
} .getColumn("name")
onChange={(event) => ?.getFilterValue() as string) ?? ""
table }
.getColumn("name") onChange={(event) =>
?.setFilterValue(event.target.value) table
} .getColumn("name")
className="max-w-sm mr-2" ?.setFilterValue(event.target.value)
/> }
className="w-full pl-8"
/>
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2" />
</div>
<Button <Button
onClick={() => { onClick={() => {
if (addSite) { if (addSite) {
@@ -85,7 +89,7 @@ export function SitesDataTable<TData, TValue>({
<Plus className="mr-2 h-4 w-4" /> Add Site <Plus className="mr-2 h-4 w-4" /> Add Site
</Button> </Button>
</div> </div>
<div> <div className="border rounded-md">
<Table> <Table>
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
@@ -98,7 +102,7 @@ export function SitesDataTable<TData, TValue>({
: flexRender( : flexRender(
header.column.columnDef header.column.columnDef
.header, .header,
header.getContext() header.getContext(),
)} )}
</TableHead> </TableHead>
); );
@@ -119,7 +123,7 @@ export function SitesDataTable<TData, TValue>({
<TableCell key={cell.id}> <TableCell key={cell.id}>
{flexRender( {flexRender(
cell.column.columnDef.cell, cell.column.columnDef.cell,
cell.getContext() cell.getContext(),
)} )}
</TableCell> </TableCell>
))} ))}

View File

@@ -24,9 +24,10 @@ export type SiteRow = {
id: number; id: number;
nice: string; nice: string;
name: string; name: string;
mbIn: number; mbIn: string;
mbOut: number; mbOut: string;
orgId: string; orgId: string;
type: "newt" | "wireguard";
}; };
type SitesTableProps = { type SitesTableProps = {
@@ -99,11 +100,70 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
}, },
{ {
accessorKey: "mbIn", accessorKey: "mbIn",
header: "MB In", header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Data In
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
}, },
{ {
accessorKey: "mbOut", accessorKey: "mbOut",
header: "MB Out", header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Data Out
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
},
{
accessorKey: "type",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Connection Type
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const originalRow = row.original;
if (originalRow.type === "newt") {
return (
<div className="flex items-center space-x-2">
<span>Newt</span>
</div>
);
}
if (originalRow.type === "wireguard") {
return (
<div className="flex items-center space-x-2">
<span>WireGuard</span>
</div>
);
}
},
}, },
{ {
id: "actions", id: "actions",
@@ -135,24 +195,21 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
setSelectedSite(siteRow); setSelectedSite(siteRow);
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
}} }}
className="text-red-600 hover:text-red-800" className="text-red-500"
> >
Delete Delete
</button> </button>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Button <Link
variant={"gray"} href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
className="ml-2"
onClick={() =>
router.push(
`/${siteRow.orgId}/settings/sites/${siteRow.nice}`
)
}
> >
Edit <ArrowRight className="ml-2 w-4 h-4" /> <Button variant={"gray"} className="ml-2">
</Button> Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div> </div>
); );
}, },

View File

@@ -15,21 +15,32 @@ export default async function SitesPage(props: SitesPageProps) {
try { try {
const res = await internal.get<AxiosResponse<ListSitesResponse>>( const res = await internal.get<AxiosResponse<ListSitesResponse>>(
`/org/${params.orgId}/sites`, `/org/${params.orgId}/sites`,
await authCookieHeader() await authCookieHeader(),
); );
sites = res.data.data.sites; sites = res.data.data.sites;
} catch (e) { } catch (e) {
console.error("Error fetching sites", e); console.error("Error fetching sites", e);
} }
function formatSize(mb: number): string {
if (mb >= 1024 * 1024) {
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;
} else if (mb >= 1024) {
return `${(mb / 1024).toFixed(2)} GB`;
} else {
return `${mb.toFixed(2)} MB`;
}
}
const siteRows: SiteRow[] = sites.map((site) => { const siteRows: SiteRow[] = sites.map((site) => {
return { return {
name: site.name, name: site.name,
id: site.siteId, id: site.siteId,
nice: site.niceId.toString(), nice: site.niceId.toString(),
mbIn: site.megabytesIn || 0, mbIn: formatSize(site.megabytesIn || 0),
mbOut: site.megabytesOut || 0, mbOut: formatSize(site.megabytesOut || 0),
orgId: params.orgId, orgId: params.orgId,
type: site.type as any,
}; };
}); });
@@ -37,7 +48,7 @@ export default async function SitesPage(props: SitesPageProps) {
<> <>
<SettingsSectionTitle <SettingsSectionTitle
title="Manage Sites" title="Manage Sites"
description="Manage your existing sites here or create a new one." description="Allow connectivity to your network through secure tunnels"
/> />
<SitesTable sites={siteRows} orgId={params.orgId} /> <SitesTable sites={siteRows} orgId={params.orgId} />

View File

@@ -30,7 +30,15 @@ export default function DashboardLoginForm({
<CardContent> <CardContent>
<LoginForm <LoginForm
redirect={redirect} redirect={redirect}
onLogin={() => router.push("/")} onLogin={() => {
if (redirect && redirect.includes("http")) {
window.location.href = redirect;
} else if (redirect) {
router.push(redirect);
} else {
router.push("/");
}
}}
/> />
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,3 +1,6 @@
"use client";
import { Button } from "@app/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
@@ -5,8 +8,9 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@app/components/ui/card"; } from "@app/components/ui/card";
import Link from "next/link";
export default async function ResourceAccessDenied() { export default function ResourceAccessDenied() {
return ( return (
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader> <CardHeader>
@@ -17,6 +21,11 @@ export default async function ResourceAccessDenied() {
<CardContent> <CardContent>
You're not alowed to access this resource. If this is a mistake, You're not alowed to access this resource. If this is a mistake,
please contact the administrator. please contact the administrator.
<div className="text-center mt-4">
<Button>
<Link href="/">Go Home</Link>
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import * as z from "zod"; import * as z from "zod";
@@ -23,7 +23,7 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { LockIcon, UserIcon, Binary, Key, User } from "lucide-react"; import { LockIcon, Binary, Key, User } from "lucide-react";
import { import {
InputOTP, InputOTP,
InputOTPGroup, InputOTPGroup,
@@ -34,10 +34,10 @@ import { useRouter } from "next/navigation";
import { Alert, AlertDescription } from "@app/components/ui/alert"; import { Alert, AlertDescription } from "@app/components/ui/alert";
import { formatAxiosError } from "@app/lib/utils"; import { formatAxiosError } from "@app/lib/utils";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { LoginResponse } from "@server/routers/auth";
import ResourceAccessDenied from "./ResourceAccessDenied";
import LoginForm from "@app/components/LoginForm"; import LoginForm from "@app/components/LoginForm";
import { AuthWithPasswordResponse } from "@server/routers/resource"; import { AuthWithPasswordResponse } from "@server/routers/resource";
import { redirect } from "next/dist/server/api-utils";
import ResourceAccessDenied from "./ResourceAccessDenied";
const pinSchema = z.object({ const pinSchema = z.object({
pin: z pin: z
@@ -63,7 +63,6 @@ type ResourceAuthPortalProps = {
id: number; id: number;
}; };
redirect: string; redirect: string;
queryParamName: string;
}; };
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
@@ -114,13 +113,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
}, },
}); });
function constructRedirect(redirect: string, token: string): string {
const redirectUrl = new URL(redirect);
redirectUrl.searchParams.delete(props.queryParamName);
redirectUrl.searchParams.append(props.queryParamName, token);
return redirectUrl.toString();
}
const onPinSubmit = (values: z.infer<typeof pinSchema>) => { const onPinSubmit = (values: z.infer<typeof pinSchema>) => {
setLoadingLogin(true); setLoadingLogin(true);
api.post<AxiosResponse<AuthWithPasswordResponse>>( api.post<AxiosResponse<AuthWithPasswordResponse>>(
@@ -130,10 +122,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
.then((res) => { .then((res) => {
const session = res.data.data.session; const session = res.data.data.session;
if (session) { if (session) {
window.location.href = constructRedirect( window.location.href = props.redirect;
props.redirect,
session,
);
} }
}) })
.catch((e) => { .catch((e) => {
@@ -156,10 +145,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
.then((res) => { .then((res) => {
const session = res.data.data.session; const session = res.data.data.session;
if (session) { if (session) {
window.location.href = constructRedirect( window.location.href = props.redirect;
props.redirect,
session,
);
} }
}) })
.catch((e) => { .catch((e) => {
@@ -172,13 +158,15 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
}; };
async function handleSSOAuth() { async function handleSSOAuth() {
console.log("SSO authentication"); let isAllowed = false;
try {
await api.get(`/resource/${props.resource.id}`).catch((e) => { await api.get(`/resource/${props.resource.id}`);
isAllowed = true;
} catch (e) {
setAccessDenied(true); setAccessDenied(true);
}); }
if (!accessDenied) { if (isAllowed) {
window.location.href = props.redirect; window.location.href = props.redirect;
} }
} }
@@ -187,6 +175,11 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
<div> <div>
{!accessDenied ? ( {!accessDenied ? (
<div> <div>
<div className="text-center mb-2">
<span className="text-sm text-muted-foreground">
Powered by Fossorial
</span>
</div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Authentication Required</CardTitle> <CardTitle>Authentication Required</CardTitle>
@@ -378,6 +371,11 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
className={`${numMethods <= 1 ? "mt-0" : ""}`} className={`${numMethods <= 1 ? "mt-0" : ""}`}
> >
<LoginForm <LoginForm
redirect={
typeof window !== "undefined"
? window.location.href
: ""
}
onLogin={async () => onLogin={async () =>
await handleSSOAuth() await handleSSOAuth()
} }

View File

@@ -1,3 +1,4 @@
import { Button } from "@app/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
@@ -5,6 +6,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@app/components/ui/card"; } from "@app/components/ui/card";
import Link from "next/link";
export default async function ResourceNotFound() { export default async function ResourceNotFound() {
return ( return (
@@ -15,7 +17,12 @@ export default async function ResourceNotFound() {
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
The resource you're trying to access does not exist The resource you're trying to access does not exist.
<div className="text-center mt-4">
<Button>
<Link href="/">Go Home</Link>
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -3,7 +3,7 @@ import {
GetResourceResponse, GetResourceResponse,
} from "@server/routers/resource"; } from "@server/routers/resource";
import ResourceAuthPortal from "./components/ResourceAuthPortal"; import ResourceAuthPortal from "./components/ResourceAuthPortal";
import { internal } from "@app/api"; import { internal, priv } from "@app/api";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/api/cookies"; import { authCookieHeader } from "@app/api/cookies";
import { cache } from "react"; import { cache } from "react";
@@ -11,10 +11,12 @@ import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import ResourceNotFound from "./components/ResourceNotFound"; import ResourceNotFound from "./components/ResourceNotFound";
import ResourceAccessDenied from "./components/ResourceAccessDenied"; import ResourceAccessDenied from "./components/ResourceAccessDenied";
import { cookies } from "next/headers";
import { CheckResourceSessionResponse } from "@server/routers/auth";
export default async function ResourceAuthPage(props: { export default async function ResourceAuthPage(props: {
params: Promise<{ resourceId: number }>; params: Promise<{ resourceId: number }>;
searchParams: Promise<{ redirect: string }>; searchParams: Promise<{ redirect: string | undefined }>;
}) { }) {
const params = await props.params; const params = await props.params;
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
@@ -31,7 +33,7 @@ export default async function ResourceAuthPage(props: {
} catch (e) {} } catch (e) {}
const getUser = cache(verifySession); const getUser = cache(verifySession);
const user = await getUser(); const user = await getUser({ skipCheckVerifyEmail: true });
if (!authInfo) { if (!authInfo) {
return ( return (
@@ -46,6 +48,40 @@ export default async function ResourceAuthPage(props: {
const redirectUrl = searchParams.redirect || authInfo.url; const redirectUrl = searchParams.redirect || authInfo.url;
if (
user &&
!user.emailVerified &&
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true"
) {
redirect(
`/auth/verify-email?redirect=/auth/resource/${authInfo.resourceId}`,
);
}
const allCookies = await cookies();
const cookieName =
process.env.RESOURCE_SESSION_COOKIE_NAME + `_${params.resourceId}`;
const sessionId = allCookies.get(cookieName)?.value ?? null;
if (sessionId) {
let doRedirect = false;
try {
const res = await priv.get<
AxiosResponse<CheckResourceSessionResponse>
>(`/resource-session/${params.resourceId}/${sessionId}`);
console.log("resource session already exists and is valid");
if (res && res.data.data.valid) {
doRedirect = true;
}
} catch (e) {}
if (doRedirect) {
redirect(redirectUrl);
}
}
if (!hasAuth) { if (!hasAuth) {
// no authentication so always go straight to the resource // no authentication so always go straight to the resource
redirect(redirectUrl); redirect(redirectUrl);
@@ -63,7 +99,6 @@ export default async function ResourceAuthPage(props: {
console.log(res.data); console.log(res.data);
doRedirect = true; doRedirect = true;
} catch (e) { } catch (e) {
console.error(e);
userIsUnauthorized = true; userIsUnauthorized = true;
} }
@@ -72,33 +107,28 @@ export default async function ResourceAuthPage(props: {
} }
} }
if (userIsUnauthorized && isSSOOnly) {
return (
<div className="w-full max-w-md">
<ResourceAccessDenied />
</div>
);
}
return ( return (
<> <>
<div className="w-full max-w-md"> {userIsUnauthorized && isSSOOnly ? (
<ResourceAuthPortal <div className="w-full max-w-md">
methods={{ <ResourceAccessDenied />
password: authInfo.password, </div>
pincode: authInfo.pincode, ) : (
sso: authInfo.sso && !userIsUnauthorized, <div className="w-full max-w-md">
}} <ResourceAuthPortal
resource={{ methods={{
name: authInfo.resourceName, password: authInfo.password,
id: authInfo.resourceId, pincode: authInfo.pincode,
}} sso: authInfo.sso && !userIsUnauthorized,
redirect={redirectUrl} }}
queryParamName={ resource={{
process.env.RESOURCE_SESSION_QUERY_PARAM_NAME! name: authInfo.resourceName,
} id: authInfo.resourceId,
/> }}
</div> redirect={redirectUrl}
/>
</div>
)}
</> </>
); );
} }

View File

@@ -79,6 +79,7 @@ export default function VerifyEmailForm({
.catch((e) => { .catch((e) => {
setError(formatAxiosError(e, "An error occurred")); setError(formatAxiosError(e, "An error occurred"));
console.error("Failed to verify email:", e); console.error("Failed to verify email:", e);
setIsSubmitting(false);
}); });
if (res && res.data?.data?.valid) { if (res && res.data?.data?.valid) {
@@ -125,7 +126,7 @@ export default function VerifyEmailForm({
<div> <div>
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader> <CardHeader>
<CardTitle>Verify Your Email</CardTitle> <CardTitle>Verify Email</CardTitle>
<CardDescription> <CardDescription>
Enter the verification code sent to your email address. Enter the verification code sent to your email address.
</CardDescription> </CardDescription>
@@ -234,7 +235,7 @@ export default function VerifyEmailForm({
</CardContent> </CardContent>
</Card> </Card>
<div className="text-center text-muted-foreground mt-4"> <div className="text-center text-muted-foreground mt-2">
<Button <Button
type="button" type="button"
variant="link" variant="link"

View File

@@ -8,13 +8,13 @@ export const dynamic = "force-dynamic";
export default async function Page(props: { export default async function Page(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) { }) {
if (process.env.PUBLIC_FLAGS_EMAIL_VERIFICATION_REQUIRED !== "true") { if (process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED !== "true") {
redirect("/"); redirect("/");
} }
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const getUser = cache(verifySession); const getUser = cache(verifySession);
const user = await getUser(); const user = await getUser({ skipCheckVerifyEmail: true });
if (!user) { if (!user) {
redirect("/"); redirect("/");

View File

@@ -6,11 +6,11 @@
@layer base { @layer base {
:root { :root {
--background: 0 0% 100%; --background: 0 0% 100%;
--foreground: 20 14.3% 4.1%; --foreground: 20 5.0% 10.0%;
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%; --card-foreground: 20 5.0% 10.0%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%; --popover-foreground: 20 5.0% 10.0%;
--primary: 24.6 95% 53.1%; --primary: 24.6 95% 53.1%;
--primary-foreground: 60 9.1% 97.8%; --primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%; --secondary: 60 4.8% 95.9%;
@@ -33,24 +33,24 @@
} }
.dark { .dark {
--background: 20 14.3% 4.1%; --background: 20 5.0% 10.0%;
--foreground: 60 9.1% 97.8%; --foreground: 60 9.1% 97.8%;
--card: 20 14.3% 4.1%; --card: 20 5.0% 10.0%;
--card-foreground: 60 9.1% 97.8%; --card-foreground: 60 9.1% 97.8%;
--popover: 20 14.3% 4.1%; --popover: 20 5.0% 10.0%;
--popover-foreground: 60 9.1% 97.8%; --popover-foreground: 60 9.1% 97.8%;
--primary: 20.5 90.2% 48.2%; --primary: 20.5 90.2% 48.2%;
--primary-foreground: 60 9.1% 97.8%; --primary-foreground: 60 9.1% 97.8%;
--secondary: 12 6.5% 15.1%; --secondary: 12 6.5% 25.0%;
--secondary-foreground: 60 9.1% 97.8%; --secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 15.1%; --muted: 12 6.5% 25.0%;
--muted-foreground: 24 5.4% 63.9%; --muted-foreground: 24 5.4% 63.9%;
--accent: 12 6.5% 15.1%; --accent: 12 6.5% 25.0%;
--accent-foreground: 60 9.1% 97.8%; --accent-foreground: 60 9.1% 97.8%;
--destructive: 0 72.2% 50.6%; --destructive: 0 72.2% 50.6%;
--destructive-foreground: 60 9.1% 97.8%; --destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 15.1%; --border: 12 6.5% 25.0%;
--input: 12 6.5% 15.1%; --input: 12 6.5% 25.0%;
--ring: 20.5 90.2% 48.2%; --ring: 20.5 90.2% 48.2%;
--chart-1: 220 70% 50%; --chart-1: 220 70% 50%;
--chart-2: 160 60% 45%; --chart-2: 160 60% 45%;

View File

@@ -21,7 +21,7 @@ export default async function InvitePage(props: {
const user = await verifySession(); const user = await verifySession();
if (!user) { if (!user) {
redirect(`/auth/login?redirect=/invite?token=${params.token}`); redirect(`/?redirect=/invite?token=${params.token}`);
} }
const parts = tokenParam.split("-"); const parts = tokenParam.split("-");

View File

@@ -12,21 +12,36 @@ import { cache } from "react";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function Page(props: { export default async function Page(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; searchParams: Promise<{ redirect: string | undefined }>;
}) { }) {
const params = await props.searchParams; // this is needed to prevent static optimization const params = await props.searchParams; // this is needed to prevent static optimization
const getUser = cache(verifySession); const getUser = cache(verifySession);
const user = await getUser(); const user = await getUser({ skipCheckVerifyEmail: true });
if (!user) { if (!user) {
redirect("/auth/login"); if (params.redirect) {
redirect(`/auth/login?redirect=${params.redirect}`);
} else {
redirect(`/auth/login`);
}
}
if (
!user.emailVerified &&
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true"
) {
if (params.redirect) {
redirect(`/auth/verify-email?redirect=${params.redirect}`);
} else {
redirect(`/auth/verify-email`);
}
} }
let orgs: ListOrgsResponse["orgs"] = []; let orgs: ListOrgsResponse["orgs"] = [];
try { try {
const res = await internal.get<AxiosResponse<ListOrgsResponse>>( const res = await internal.get<AxiosResponse<ListOrgsResponse>>(
`/orgs`, `/orgs`,
await authCookieHeader() await authCookieHeader(),
); );
if (res && res.data.data.orgs) { if (res && res.data.data.orgs) {

View File

@@ -1,14 +1,26 @@
import { verifySession } from "@app/lib/auth/verifySession";
import { Metadata } from "next"; import { Metadata } from "next";
import { redirect } from "next/navigation";
import { cache } from "react";
export const metadata: Metadata = { export const metadata: Metadata = {
title: `Setup - Pangolin`, title: `Setup - Pangolin`,
description: "", description: "",
}; };
export const dynamic = "force-dynamic";
export default async function SetupLayout({ export default async function SetupLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const getUser = cache(verifySession);
const user = await getUser();
if (!user) {
redirect("/?redirect=/setup");
}
return <div className="mt-32">{children}</div>; return <div className="mt-32">{children}</div>;
} }

View File

@@ -44,7 +44,7 @@ export default function StepperForm() {
const debouncedCheckOrgIdAvailability = useCallback( const debouncedCheckOrgIdAvailability = useCallback(
debounce(checkOrgIdAvailability, 300), debounce(checkOrgIdAvailability, 300),
[checkOrgIdAvailability] [checkOrgIdAvailability],
); );
useEffect(() => { useEffect(() => {
@@ -278,7 +278,7 @@ export default function StepperForm() {
function debounce<T extends (...args: any[]) => any>( function debounce<T extends (...args: any[]) => any>(
func: T, func: T,
wait: number wait: number,
): (...args: Parameters<T>) => void { ): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null; let timeout: NodeJS.Timeout | null = null;

View File

@@ -75,8 +75,6 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
if (res && res.status === 200) { if (res && res.status === 200) {
setError(null); setError(null);
console.log(res);
if (res.data?.data?.emailVerificationRequired) { if (res.data?.data?.emailVerificationRequired) {
if (redirect) { if (redirect) {
router.push(`/auth/verify-email?redirect=${redirect}`); router.push(`/auth/verify-email?redirect=${redirect}`);
@@ -86,14 +84,8 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
return; return;
} }
if (redirect && redirect.includes("http")) { if (onLogin) {
window.location.href = redirect; await onLogin();
} else if (redirect) {
router.push(redirect);
} else {
if (onLogin) {
await onLogin();
}
} }
} }

View File

@@ -3,15 +3,33 @@ import { authCookieHeader } from "@app/api/cookies";
import { GetUserResponse } from "@server/routers/user"; import { GetUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
export async function verifySession(): Promise<GetUserResponse | null> { export async function verifySession({
skipCheckVerifyEmail,
}: {
skipCheckVerifyEmail?: boolean;
} = {}): Promise<GetUserResponse | null> {
try { try {
const res = await internal.get<AxiosResponse<GetUserResponse>>( const res = await internal.get<AxiosResponse<GetUserResponse>>(
"/user", "/user",
await authCookieHeader() await authCookieHeader(),
); );
return res.data.data; const user = res.data.data;
} catch {
if (!user) {
return null;
}
if (
!skipCheckVerifyEmail &&
!user.emailVerified &&
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED == "true"
) {
return null;
}
return user;
} catch (e) {
return null; return null;
} }
} }