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:
base_url: http://localhost:3000
base_url: https://fossorial.io
log_level: debug
save_logs: false
server:
external_port: 3000
internal_port: 3001
internal_hostname: localhost
internal_hostname: pangolin
secure_cookies: false
session_cookie_name: session
resource_session_cookie_name: resource_session
traefik:
cert_resolver: letsencrypt
http_entrypoint: web
https_entrypoint: websecure
badger:
session_query_parameter: __pang_sess
resource_session_cookie_name: resource_session
prefer_wildcard_cert: true
gerbil:
start_port: 51820
base_endpoint: 127.0.0.1
base_endpoint: fossorial.io
use_subdomain: false
block_size: 16
subnet_group: 10.0.0.0/8

View File

@@ -4,13 +4,13 @@
"private": true,
"type": "module",
"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:push": "npx tsx server/db/migrate.ts",
"db:hydrate": "npx tsx scripts/hydrate.ts",
"db:studio": "drizzle-kit studio",
"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"
},
"dependencies": {

View File

@@ -3,9 +3,13 @@ import { sha256 } from "@oslojs/crypto/sha2";
import { resourceSessions, ResourceSession } from "@server/db/schema";
import db from "@server/db";
import { eq, and } from "drizzle-orm";
import config from "@server/config";
export const SESSION_COOKIE_NAME = "resource_session";
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: {
token: string;
@@ -115,25 +119,25 @@ export async function invalidateAllSessions(
}
export function serializeResourceSessionCookie(
cookieName: string,
token: string,
fqdn: string,
secure: boolean,
): string {
if (secure) {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=.localhost`;
if (SECURE_COOKIES) {
return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} 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(
cookieName: string,
fqdn: string,
secure: boolean,
): string {
if (secure) {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${fqdn}`;
if (SECURE_COOKIES) {
return `${cookieName}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} 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(),
signup_secret: z.string().optional(),
session_cookie_name: z.string(),
}),
badger: z.object({
session_query_parameter: z.string(),
resource_session_cookie_name: z.string(),
}),
traefik: z.object({
@@ -128,12 +125,14 @@ if (!parsedConfig.success) {
process.env.SERVER_EXTERNAL_PORT =
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
?.require_email_verification
? "true"
: "false";
process.env.SESSION_COOKIE_NAME = parsedConfig.data.server.session_cookie_name;
process.env.RESOURCE_SESSION_QUERY_PARAM_NAME =
parsedConfig.data.badger.session_query_parameter;
process.env.RESOURCE_SESSION_COOKIE_NAME =
parsedConfig.data.server.resource_session_cookie_name;
export default parsedConfig.data;

View File

@@ -287,16 +287,28 @@ export const resourceSessions = sqliteTable("resourceSessions", {
() => resourcePassword.passwordId,
{
onDelete: "cascade",
}
},
),
pincodeId: integer("pincodeId").references(
() => resourcePincode.pincodeId,
{
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 User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;
@@ -325,3 +337,4 @@ export type UserOrg = InferSelectModel<typeof userOrgs>;
export type ResourceSession = InferSelectModel<typeof resourceSessions>;
export type ResourcePincode = InferSelectModel<typeof resourcePincode>;
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 emailClient from "@server/emails";
import logger from "@server/logger";

View File

@@ -33,9 +33,17 @@ export const SendInviteLink = ({
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind>
<Tailwind config={{
theme: {
extend: {
colors: {
primary: "#F97317"
}
}
}
}}>
<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">
You're invited to join a Fossorial organization
</Heading>
@@ -58,7 +66,7 @@ export const SendInviteLink = ({
<Section className="text-center my-6">
<Button
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}
</Button>

View File

@@ -14,11 +14,13 @@ import * as React from "react";
interface VerifyEmailProps {
username?: string;
verificationCode: string;
verifyLink: string;
}
export const VerifyEmail = ({
username,
verificationCode,
verifyLink,
}: VerifyEmailProps) => {
const previewText = `Verify your email, ${username}`;
@@ -26,21 +28,34 @@ export const VerifyEmail = ({
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind>
<Tailwind
config={{
theme: {
extend: {
colors: {
primary: "#F97317",
},
},
},
}}
>
<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">
Verify Your Email
Please verify your email
</Heading>
<Text className="text-base text-gray-700 mt-4">
Hi {username || "there"},
</Text>
<Text className="text-base text-gray-700 mt-2">
Youve requested to verify your email. Please use
the verification code below:
Youve requested to verify your email. Please{" "}
<a href={verifyLink} className="text-primary">
click here
</a>{" "}
to verify your email, then enter the following code:
</Text>
<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}
</Text>
</Section>
@@ -59,3 +74,5 @@ export const VerifyEmail = ({
</Html>
);
};
export default VerifyEmail;

View File

@@ -51,7 +51,7 @@ app.prepare().then(() => {
externalServer.use(logIncomingMiddleware);
externalServer.use(prefix, unauthenticated);
externalServer.use(prefix, authenticated);
externalServer.use(`${prefix}/ws`, wsRouter);
// externalServer.use(`${prefix}/ws`, wsRouter);
externalServer.use(notFoundMiddleware);
@@ -68,7 +68,7 @@ app.prepare().then(() => {
);
});
handleWSUpgrade(httpServer);
// handleWSUpgrade(httpServer);
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 "./requestPasswordReset";
export * from "./resetPassword";
export * from "./checkResourceSession";

View File

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

View File

@@ -13,11 +13,18 @@ export async function sendEmailVerificationCode(
): Promise<void> {
const code = await generateEmailVerificationCode(userId, email);
await sendEmail(VerifyEmail({ username: email, verificationCode: code }), {
to: email,
from: config.email?.no_reply,
subject: "Verify your email address",
});
await sendEmail(
VerifyEmail({
username: email,
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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,8 @@ import {
sites,
userResources,
roleResources,
resourcePassword,
resourcePincode,
} from "@server/db/schema";
import response from "@server/utils/response";
import HttpCode from "@server/types/HttpCode";
@@ -46,39 +48,65 @@ const listResourcesSchema = z.object({
function queryResources(
accessibleResourceIds: number[],
siteId?: number,
orgId?: string
orgId?: string,
) {
if (siteId) {
return db
.select({
resourceId: resources.resourceId,
name: resources.name,
subdomain: resources.subdomain,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
siteName: sites.name,
siteId: sites.niceId,
passwordId: resourcePassword.passwordId,
pincodeId: resourcePincode.pincodeId,
sso: resources.sso,
})
.from(resources)
.leftJoin(sites, eq(resources.siteId, sites.siteId))
.leftJoin(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId),
)
.leftJoin(
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId),
)
.where(
and(
inArray(resources.resourceId, accessibleResourceIds),
eq(resources.siteId, siteId)
)
eq(resources.siteId, siteId),
),
);
} else if (orgId) {
return db
.select({
resourceId: resources.resourceId,
name: resources.name,
subdomain: resources.subdomain,
ssl: resources.ssl,
fullDomain: resources.fullDomain,
siteName: sites.name,
siteId: sites.niceId,
passwordId: resourcePassword.passwordId,
sso: resources.sso,
pincodeId: resourcePincode.pincodeId,
})
.from(resources)
.leftJoin(sites, eq(resources.siteId, sites.siteId))
.leftJoin(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId),
)
.leftJoin(
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId),
)
.where(
and(
inArray(resources.resourceId, accessibleResourceIds),
eq(resources.orgId, orgId)
)
eq(resources.orgId, orgId),
),
);
}
}
@@ -91,7 +119,7 @@ export type ListResourcesResponse = {
export async function listResources(
req: Request,
res: Response,
next: NextFunction
next: NextFunction,
): Promise<any> {
try {
const parsedQuery = listResourcesSchema.safeParse(req.query);
@@ -99,8 +127,8 @@ export async function listResources(
return next(
createHttpError(
HttpCode.BAD_REQUEST,
parsedQuery.error.errors.map((e) => e.message).join(", ")
)
parsedQuery.error.errors.map((e) => e.message).join(", "),
),
);
}
const { limit, offset } = parsedQuery.data;
@@ -110,8 +138,8 @@ export async function listResources(
return next(
createHttpError(
HttpCode.BAD_REQUEST,
parsedParams.error.errors.map((e) => e.message).join(", ")
)
parsedParams.error.errors.map((e) => e.message).join(", "),
),
);
}
const { siteId, orgId } = parsedParams.data;
@@ -120,8 +148,8 @@ export async function listResources(
return next(
createHttpError(
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)
.fullJoin(
roleResources,
eq(userResources.resourceId, roleResources.resourceId)
eq(userResources.resourceId, roleResources.resourceId),
)
.where(
or(
eq(userResources.userId, req.user!.userId),
eq(roleResources.roleId, req.userOrgRoleId!)
)
eq(roleResources.roleId, req.userOrgRoleId!),
),
);
const accessibleResourceIds = accessibleResources.map(
(resource) => resource.resourceId
(resource) => resource.resourceId,
);
let countQuery: any = db
@@ -173,7 +201,10 @@ export async function listResources(
} catch (error) {
logger.error(error);
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,
megabytesOut: sites.megabytesOut,
orgName: orgs.name,
type: sites.type,
})
.from(sites)
.leftJoin(orgs, eq(sites.orgId, orgs.orgId))
.where(
and(
inArray(sites.siteId, accessibleSiteIds),
eq(sites.orgId, orgId)
)
eq(sites.orgId, orgId),
),
);
}
@@ -57,7 +58,7 @@ export type ListSitesResponse = {
export async function listSites(
req: Request,
res: Response,
next: NextFunction
next: NextFunction,
): Promise<any> {
try {
const parsedQuery = listSitesSchema.safeParse(req.query);
@@ -65,8 +66,8 @@ export async function listSites(
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
fromError(parsedQuery.error),
),
);
}
const { limit, offset } = parsedQuery.data;
@@ -76,8 +77,8 @@ export async function listSites(
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
fromError(parsedParams.error),
),
);
}
const { orgId } = parsedParams.data;
@@ -86,8 +87,8 @@ export async function listSites(
return next(
createHttpError(
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(
or(
eq(userSites.userId, req.user!.userId),
eq(roleSites.roleId, req.userOrgRoleId!)
)
eq(roleSites.roleId, req.userOrgRoleId!),
),
);
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
@@ -113,8 +114,8 @@ export async function listSites(
.where(
and(
inArray(sites.siteId, accessibleSiteIds),
eq(sites.orgId, orgId)
)
eq(sites.orgId, orgId),
),
);
const sitesList = await baseQuery.limit(limit).offset(offset);
@@ -137,7 +138,10 @@ export async function listSites(
});
} catch (error) {
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,
eq(schema.resources.orgId, schema.orgs.orgId),
)
.innerJoin(
schema.sites,
eq(schema.sites.siteId, schema.resources.siteId),
)
.where(
and(
eq(schema.targets.enabled, true),
@@ -51,11 +55,9 @@ export async function traefikConfigProvider(
`http://${config.server.internal_hostname}:${config.server.internal_port}`,
).href,
resourceSessionCookieName:
config.badger.resource_session_cookie_name,
config.server.resource_session_cookie_name,
userSessionCookieName:
config.server.session_cookie_name,
sessionQueryParameter:
config.badger.session_query_parameter,
},
},
},
@@ -70,6 +72,7 @@ export async function traefikConfigProvider(
for (const item of all) {
const target = item.targets;
const resource = item.resources;
const site = item.sites;
const org = item.orgs;
const routerName = `${target.targetId}-router`;
@@ -112,7 +115,7 @@ export async function traefikConfigProvider(
? config.traefik.https_entrypoint
: config.traefik.http_entrypoint,
],
middlewares: resource.ssl ? [badgerMiddlewareName] : [],
middlewares: [badgerMiddlewareName],
service: serviceName,
rule: `Host(\`${fullDomain}\`)`,
...(resource.ssl ? { tls } : {}),
@@ -128,15 +131,28 @@ export async function traefikConfigProvider(
};
}
http.services![serviceName] = {
loadBalancer: {
servers: [
{
url: `${target.method}://${target.ip}:${target.port}`,
},
],
},
};
if (site.type === "newt") {
const ip = site.subnet.split("/")[0];
http.services![serviceName] = {
loadBalancer: {
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 });

View File

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

View File

@@ -1,6 +1,8 @@
import { internal } from "@app/api";
import { authCookieHeader } from "@app/api/cookies";
import { verifySession } from "@app/lib/auth/verifySession";
import { GetOrgResponse } from "@server/routers/org";
import { GetOrgUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { cache } from "react";
@@ -17,6 +19,25 @@ export default async function OrgLayout(props: {
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 {
const getOrg = cache(() =>
internal.get<AxiosResponse<GetOrgResponse>>(

View File

@@ -14,27 +14,6 @@ export default async function OrgPage(props: OrgPageProps) {
const params = await props.params;
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 (
<>
<p>Welcome to {orgId} dashboard</p>

View File

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

View File

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

View File

@@ -65,6 +65,14 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
return (
<>
<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 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -81,7 +89,7 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
<DropdownMenuContent align="end">
<DropdownMenuItem>
<button
className="text-red-600 hover:text-red-800"
className="text-red-500"
onClick={() => {
setIsDeleteModalOpen(true);
setUserToRemove(roleRow);
@@ -117,7 +125,9 @@ export default function UsersTable({ roles: r }: RolesTableProps) {
roleToDelete={roleToRemove}
afterDelete={() => {
setRoles((prev) =>
prev.filter((r) => r.roleId !== roleToRemove.roleId)
prev.filter(
(r) => r.roleId !== roleToRemove.roleId,
),
);
setUserToRemove(null);
}}

View File

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

View File

@@ -99,7 +99,9 @@ export default function UsersTable({ users: u }: UsersTableProps) {
return (
<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>
</div>
);
@@ -113,6 +115,14 @@ export default function UsersTable({ users: u }: UsersTableProps) {
return (
<>
<div className="flex items-center justify-end">
{userRow.isOwner && (
<Button
variant="ghost"
className="opacity-0 cursor-default"
>
Placeholder
</Button>
)}
{!userRow.isOwner && (
<>
<DropdownMenu>
@@ -138,13 +148,13 @@ export default function UsersTable({ users: u }: UsersTableProps) {
{userRow.email !== user?.email && (
<DropdownMenuItem>
<button
className="text-red-600 hover:text-red-800"
className="text-red-500"
onClick={() => {
setIsDeleteModalOpen(
true
true,
);
setSelectedUser(
userRow
userRow,
);
}}
>
@@ -154,18 +164,17 @@ export default function UsersTable({ users: u }: UsersTableProps) {
)}
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"gray"}
className="ml-2"
onClick={() =>
router.push(
`/${org?.org.orgId}/settings/access/users/${userRow.id}`
)
}
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
Manage{" "}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
<Button
variant={"gray"}
className="ml-2"
>
Manage
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</>
)}
</div>
@@ -185,7 +194,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
title: "Failed to remove user",
description: formatAxiosError(
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) =>
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"
role="combobox"
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 flex-col items-start">
@@ -202,29 +202,6 @@ export default function Header({ email, orgId, name, orgs }: HeaderProps) {
</Command>
</PopoverContent>
</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>
</>

View File

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

View File

@@ -55,7 +55,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const user = await getUser();
if (!user) {
redirect("/auth/login");
redirect(`/?redirect=/${params.orgId}/`);
}
const cookie = await authCookieHeader();
@@ -64,8 +64,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const getOrgUser = cache(() =>
internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${params.orgId}/user/${user.userId}`,
cookie
)
cookie,
),
);
const orgUser = await getOrgUser();
@@ -79,7 +79,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
let orgs: ListOrgsResponse["orgs"] = [];
try {
const getOrgs = cache(() =>
internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie)
internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie),
);
const res = await getOrgs();
if (res && res.data.data.orgs) {
@@ -91,7 +91,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
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 ">
<Header
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">
<SettingsSectionTitle
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"
/>
@@ -320,11 +320,13 @@ export default function ResourceAuthenticationPage() {
defaultChecked={resource.sso}
onCheckedChange={(val) => setSsoEnabled(val)}
/>
<Label htmlFor="sso-toggle">Allow SSO</Label>
<Label htmlFor="sso-toggle">
Allow Unified Login
</Label>
</div>
<span className="text-muted-foreground text-sm">
Existing users will only have to login once for all
resources that have SSO enabled.
resources that have this enabled.
</span>
</div>
@@ -460,7 +462,7 @@ export default function ResourceAuthenticationPage() {
<Separator />
<section className="space-y-8 lg:max-w-2xl">
<section className="space-y-8">
<SettingsSectionTitle
title="Authentication 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>[] = [
{
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",
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",
// header: "Protocol",
@@ -368,7 +368,7 @@ export default function ReverseProxyTargets(props: {
</div>
</section>
<hr />
<hr className="lg:max-w-2xl" />
<section className="space-y-8">
<SettingsSectionTitle
@@ -386,25 +386,6 @@ export default function ReverseProxyTargets(props: {
className="space-y-8"
>
<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
control={addTargetForm.control}
name="method"
@@ -444,6 +425,25 @@ export default function ReverseProxyTargets(props: {
</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
control={addTargetForm.control}
name="port"

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,16 @@ import {
DropdownMenuTrigger,
} from "@app/components/ui/dropdown-menu";
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 { useRouter } from "next/navigation";
import api from "@app/api";
@@ -26,6 +35,8 @@ export type ResourceRow = {
orgId: string;
domain: string;
site: string;
siteId: string;
hasAuth: boolean;
};
type ResourcesTableProps = {
@@ -91,10 +102,111 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
</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",
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",
@@ -130,28 +242,25 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
<button
onClick={() => {
setSelectedResource(
resourceRow
resourceRow,
);
setIsDeleteModalOpen(true);
}}
className="text-red-600 hover:text-red-800 hover:underline cursor-pointer"
className="text-red-500"
>
Delete
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"gray"}
className="ml-2"
onClick={() =>
router.push(
`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`
)
}
<Link
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
>
Edit <ArrowRight className="ml-2 w-4 h-4" />
</Button>
<Button variant={"gray"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
</>
);

View File

@@ -19,7 +19,7 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
try {
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
`/org/${params.orgId}/resources`,
await authCookieHeader()
await authCookieHeader(),
);
resources = res.data.data.resources;
} catch (e) {
@@ -31,8 +31,8 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${params.orgId}`,
await authCookieHeader()
)
await authCookieHeader(),
),
);
const res = await getOrg();
org = res.data.data;
@@ -49,8 +49,13 @@ export default async function ResourcesPage(props: ResourcesPageProps) {
id: resource.resourceId,
name: resource.name,
orgId: params.orgId,
domain: resource.subdomain || "",
domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`,
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 { Input } from "@app/components/ui/input";
import { DataTablePagination } from "../../../../../components/DataTablePagination";
import { Plus } from "lucide-react";
import { Plus, Search } from "lucide-react";
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
@@ -62,19 +62,23 @@ export function SitesDataTable<TData, TValue>({
return (
<div>
<div className="flex items-center justify-between pb-4">
<Input
placeholder="Search sites"
value={
(table.getColumn("name")?.getFilterValue() as string) ??
""
}
onChange={(event) =>
table
.getColumn("name")
?.setFilterValue(event.target.value)
}
className="max-w-sm mr-2"
/>
<div className="flex items-center max-w-sm mr-2 w-full relative">
<Input
placeholder="Search sites"
value={
(table
.getColumn("name")
?.getFilterValue() as string) ?? ""
}
onChange={(event) =>
table
.getColumn("name")
?.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
onClick={() => {
if (addSite) {
@@ -85,7 +89,7 @@ export function SitesDataTable<TData, TValue>({
<Plus className="mr-2 h-4 w-4" /> Add Site
</Button>
</div>
<div>
<div className="border rounded-md">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@@ -98,7 +102,7 @@ export function SitesDataTable<TData, TValue>({
: flexRender(
header.column.columnDef
.header,
header.getContext()
header.getContext(),
)}
</TableHead>
);
@@ -119,7 +123,7 @@ export function SitesDataTable<TData, TValue>({
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
cell.getContext(),
)}
</TableCell>
))}

View File

@@ -24,9 +24,10 @@ export type SiteRow = {
id: number;
nice: string;
name: string;
mbIn: number;
mbOut: number;
mbIn: string;
mbOut: string;
orgId: string;
type: "newt" | "wireguard";
};
type SitesTableProps = {
@@ -99,11 +100,70 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
},
{
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",
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",
@@ -135,24 +195,21 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
setSelectedSite(siteRow);
setIsDeleteModalOpen(true);
}}
className="text-red-600 hover:text-red-800"
className="text-red-500"
>
Delete
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={"gray"}
className="ml-2"
onClick={() =>
router.push(
`/${siteRow.orgId}/settings/sites/${siteRow.nice}`
)
}
<Link
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
Edit <ArrowRight className="ml-2 w-4 h-4" />
</Button>
<Button variant={"gray"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</div>
);
},

View File

@@ -15,21 +15,32 @@ export default async function SitesPage(props: SitesPageProps) {
try {
const res = await internal.get<AxiosResponse<ListSitesResponse>>(
`/org/${params.orgId}/sites`,
await authCookieHeader()
await authCookieHeader(),
);
sites = res.data.data.sites;
} catch (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) => {
return {
name: site.name,
id: site.siteId,
nice: site.niceId.toString(),
mbIn: site.megabytesIn || 0,
mbOut: site.megabytesOut || 0,
mbIn: formatSize(site.megabytesIn || 0),
mbOut: formatSize(site.megabytesOut || 0),
orgId: params.orgId,
type: site.type as any,
};
});
@@ -37,7 +48,7 @@ export default async function SitesPage(props: SitesPageProps) {
<>
<SettingsSectionTitle
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} />

View File

@@ -30,7 +30,15 @@ export default function DashboardLoginForm({
<CardContent>
<LoginForm
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>
</Card>

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import { Button } from "@app/components/ui/button";
import {
Card,
CardContent,
@@ -5,6 +6,7 @@ import {
CardHeader,
CardTitle,
} from "@app/components/ui/card";
import Link from "next/link";
export default async function ResourceNotFound() {
return (
@@ -15,7 +17,12 @@ export default async function ResourceNotFound() {
</CardTitle>
</CardHeader>
<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>
</Card>
);

View File

@@ -3,7 +3,7 @@ import {
GetResourceResponse,
} from "@server/routers/resource";
import ResourceAuthPortal from "./components/ResourceAuthPortal";
import { internal } from "@app/api";
import { internal, priv } from "@app/api";
import { AxiosResponse } from "axios";
import { authCookieHeader } from "@app/api/cookies";
import { cache } from "react";
@@ -11,10 +11,12 @@ import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import ResourceNotFound from "./components/ResourceNotFound";
import ResourceAccessDenied from "./components/ResourceAccessDenied";
import { cookies } from "next/headers";
import { CheckResourceSessionResponse } from "@server/routers/auth";
export default async function ResourceAuthPage(props: {
params: Promise<{ resourceId: number }>;
searchParams: Promise<{ redirect: string }>;
searchParams: Promise<{ redirect: string | undefined }>;
}) {
const params = await props.params;
const searchParams = await props.searchParams;
@@ -31,7 +33,7 @@ export default async function ResourceAuthPage(props: {
} catch (e) {}
const getUser = cache(verifySession);
const user = await getUser();
const user = await getUser({ skipCheckVerifyEmail: true });
if (!authInfo) {
return (
@@ -46,6 +48,40 @@ export default async function ResourceAuthPage(props: {
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) {
// no authentication so always go straight to the resource
redirect(redirectUrl);
@@ -63,7 +99,6 @@ export default async function ResourceAuthPage(props: {
console.log(res.data);
doRedirect = true;
} catch (e) {
console.error(e);
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 (
<>
<div className="w-full max-w-md">
<ResourceAuthPortal
methods={{
password: authInfo.password,
pincode: authInfo.pincode,
sso: authInfo.sso && !userIsUnauthorized,
}}
resource={{
name: authInfo.resourceName,
id: authInfo.resourceId,
}}
redirect={redirectUrl}
queryParamName={
process.env.RESOURCE_SESSION_QUERY_PARAM_NAME!
}
/>
</div>
{userIsUnauthorized && isSSOOnly ? (
<div className="w-full max-w-md">
<ResourceAccessDenied />
</div>
) : (
<div className="w-full max-w-md">
<ResourceAuthPortal
methods={{
password: authInfo.password,
pincode: authInfo.pincode,
sso: authInfo.sso && !userIsUnauthorized,
}}
resource={{
name: authInfo.resourceName,
id: authInfo.resourceId,
}}
redirect={redirectUrl}
/>
</div>
)}
</>
);
}

View File

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

View File

@@ -8,13 +8,13 @@ export const dynamic = "force-dynamic";
export default async function Page(props: {
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("/");
}
const searchParams = await props.searchParams;
const getUser = cache(verifySession);
const user = await getUser();
const user = await getUser({ skipCheckVerifyEmail: true });
if (!user) {
redirect("/");

View File

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

View File

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

View File

@@ -12,21 +12,36 @@ import { cache } from "react";
export const dynamic = "force-dynamic";
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 getUser = cache(verifySession);
const user = await getUser();
const user = await getUser({ skipCheckVerifyEmail: true });
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"] = [];
try {
const res = await internal.get<AxiosResponse<ListOrgsResponse>>(
`/orgs`,
await authCookieHeader()
await authCookieHeader(),
);
if (res && res.data.data.orgs) {

View File

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

View File

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

View File

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

View File

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