This commit is contained in:
Owen Schwartz 2024-12-24 12:09:14 -05:00
commit 0a86f193ac
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
75 changed files with 1983 additions and 2559 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@fossorial/pangolin",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
@ -60,6 +60,7 @@
"node-fetch": "3.3.2",
"nodemailer": "6.9.15",
"oslo": "1.2.1",
"qrcode.react": "4.2.0",
"react": "19.0.0-rc.1",
"react-dom": "19.0.0-rc.1",
"react-hook-form": "7.53.0",
@ -74,7 +75,6 @@
"zod-validation-error": "3.4.0"
},
"devDependencies": {
"react-email": "3.0.2",
"@dotenvx/dotenvx": "1.14.2",
"@esbuild-plugins/tsconfig-paths": "0.1.2",
"@types/better-sqlite3": "7.6.11",
@ -92,6 +92,7 @@
"esbuild": "0.20.1",
"esbuild-node-externals": "1.13.0",
"postcss": "^8",
"react-email": "3.0.2",
"tailwindcss": "^3.4.1",
"tsc-alias": "1.8.10",
"tsx": "4.19.1",

View file

@ -4,19 +4,22 @@ import { twoFactorBackupCodes } from "@server/db/schema";
import { eq } from "drizzle-orm";
import { decodeHex } from "oslo/encoding";
import { TOTPController } from "oslo/otp";
import { verifyPassword } from "./password";
export async function verifyTotpCode(
code: string,
secret: string,
userId: string,
userId: string
): Promise<boolean> {
if (code.length !== 6) {
// if code is digits only, it's totp
const isTotp = /^\d+$/.test(code);
if (!isTotp) {
const validBackupCode = await verifyBackUpCode(code, userId);
return validBackupCode;
} else {
const validOTP = await new TOTPController().verify(
code,
decodeHex(secret),
decodeHex(secret)
);
return validOTP;
@ -25,7 +28,7 @@ export async function verifyTotpCode(
export async function verifyBackUpCode(
code: string,
userId: string,
userId: string
): Promise<boolean> {
const allHashed = await db
.select()
@ -38,12 +41,7 @@ export async function verifyBackUpCode(
let validId;
for (const hashedCode of allHashed) {
const validCode = await verify(hashedCode.codeHash, code, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
const validCode = await verifyPassword(code, hashedCode.codeHash);
if (validCode) {
validId = hashedCode.codeId;
}

View file

@ -8,6 +8,7 @@ import { sendEmail } from "@server/emails";
import ResourceOTPCode from "@server/emails/templates/ResourceOTPCode";
import config from "@server/config";
import { hash, verify } from "@node-rs/argon2";
import { hashPassword } from "./password";
export async function sendResourceOtpEmail(
email: string,
@ -47,12 +48,7 @@ export async function generateResourceOtpCode(
const otp = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
const otpHash = await hash(otp, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
const otpHash = await hashPassword(otp);
await db.insert(resourceOtp).values({
resourceId,
@ -84,12 +80,7 @@ export async function isValidOtp(
return false;
}
const validCode = await verify(record[0].otpHash, otp, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
});
const validCode = await verifyPassword(otp, record[0].otpHash);
if (!validCode) {
return false;
}

View file

@ -132,6 +132,17 @@ if (!parsedConfig.success) {
throw new Error(`Invalid configuration file: ${errors}`);
}
const packageJsonPath = path.join(__DIRNAME, "..", "package.json");
let packageJson: any;
if (fs.existsSync && fs.existsSync(packageJsonPath)) {
const packageJsonContent = fs.readFileSync(packageJsonPath, "utf8");
packageJson = JSON.parse(packageJsonContent);
if (packageJson.version) {
process.env.APP_VERSION = packageJson.version;
}
}
process.env.NEXT_PORT = parsedConfig.data.server.next_port.toString();
process.env.SERVER_EXTERNAL_PORT =
parsedConfig.data.server.external_port.toString();

View file

@ -150,6 +150,7 @@ export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
export const passwordResetTokens = sqliteTable("passwordResetTokens", {
tokenId: integer("id").primaryKey({ autoIncrement: true }),
email: text("email").notNull(),
userId: text("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),

View file

@ -0,0 +1,70 @@
import {
Body,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
Tailwind
} from "@react-email/components";
import * as React from "react";
interface Props {
email: string;
}
export const ConfirmPasswordReset = ({ email }: Props) => {
const previewText = `Your password has been reset`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: {
primary: "#16A34A"
}
}
}
}}
>
<Body className="font-sans">
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
<Heading className="text-2xl font-semibold text-gray-800 text-center">
Your password has been successfully reset
</Heading>
<Text className="text-base text-gray-700 mt-4">
Hi {email || "there"},
</Text>
<Text className="text-base text-gray-700 mt-2">
This email confirms that your password has just been
reset. If you made this change, no further action is
required.
</Text>
<Section className="text-center my-6">
<Text className="text-base text-gray-700">
If you did not request this change, please
contact our support team immediately.
</Text>
</Section>
<Text className="text-base text-gray-700 mt-2">
Thank you for keeping your account secure.
</Text>
<Text className="text-sm text-gray-500 mt-6">
Best regards,
<br />
Fossorial
</Text>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default ConfirmPasswordReset;

View file

@ -0,0 +1,75 @@
import {
Body,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
Tailwind
} from "@react-email/components";
import * as React from "react";
interface Props {
email: string;
code: string;
link: string;
}
export const ResetPasswordCode = ({ email, code, link }: Props) => {
const previewText = `Reset your password, ${email}`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<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 rounded-lg">
<Heading className="text-2xl font-semibold text-gray-800 text-center">
You've requested to reset your password
</Heading>
<Text className="text-base text-gray-700 mt-4">
Hi {email || "there"},
</Text>
<Text className="text-base text-gray-700 mt-2">
Youve requested to reset your password. Please{" "}
<a href={link} className="text-primary">
click here
</a>{" "}
and follow the instructions to reset your password,
or manually enter the following code:
</Text>
<Section className="text-center my-6">
<Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl">
{code}
</Text>
</Section>
<Text className="text-base text-gray-700 mt-2">
If you didnt request this, you can safely ignore
this email.
</Text>
<Text className="text-sm text-gray-500 mt-6">
Best regards,
<br />
Fossorial
</Text>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default ResetPasswordCode;

View file

@ -61,6 +61,12 @@ export const ResourceOTPCode = ({
{otp}
</Text>
</Section>
<Text className="text-sm text-gray-500 mt-6">
Best regards,
<br />
Fossorial
</Text>
</Container>
</Body>
</Tailwind>

View file

@ -8,7 +8,7 @@ import {
Section,
Text,
Tailwind,
Button,
Button
} from "@react-email/components";
import * as React from "react";
@ -25,7 +25,7 @@ export const SendInviteLink = ({
inviteLink,
orgName,
inviterName,
expiresInDays,
expiresInDays
}: SendInviteLinkProps) => {
const previewText = `${inviterName} invited to join ${orgName}`;
@ -33,7 +33,8 @@ export const SendInviteLink = ({
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind config={{
<Tailwind
config={{
theme: {
extend: {
colors: {
@ -41,7 +42,8 @@ export const SendInviteLink = ({
}
}
}
}}>
}}
>
<Body className="font-sans">
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
<Heading className="text-2xl font-semibold text-gray-800 text-center">
@ -71,6 +73,12 @@ export const SendInviteLink = ({
Accept invitation to {orgName}
</Button>
</Section>
<Text className="text-sm text-gray-500 mt-6">
Best regards,
<br />
Fossorial
</Text>
</Container>
</Body>
</Tailwind>

View file

@ -63,6 +63,11 @@ export const VerifyEmail = ({
If you didnt request this, you can safely ignore
this email.
</Text>
<Text className="text-sm text-gray-500 mt-6">
Best regards,
<br />
Fossorial
</Text>
</Container>
</Body>
</Tailwind>

View file

@ -19,6 +19,7 @@ import { z } from "zod";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { createDate, TimeSpan } from "oslo";
import { hashPassword } from "@server/auth/password";
export const generateAccessTokenBodySchema = z
.object({
@ -91,12 +92,7 @@ export async function generateAccessToken(
const token = generateIdFromEntropySize(25);
const tokenHash = await hash(token, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
});
const tokenHash = await hashPassword(token);
const id = generateId(15);
const [result] = await db

View file

@ -3,7 +3,7 @@ import {
createSession,
generateSessionToken,
serializeSessionCookie,
verifySession,
verifySession
} from "@server/auth";
import db from "@server/db";
import { users } from "@server/db/schema";
@ -17,12 +17,15 @@ import { fromError } from "zod-validation-error";
import { verifyTotpCode } from "@server/auth/2fa";
import config from "@server/config";
import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password";
export const loginBodySchema = z.object({
export const loginBodySchema = z
.object({
email: z.string().email(),
password: z.string(),
code: z.string().optional(),
}).strict();
code: z.string().optional()
})
.strict();
export type LoginBody = z.infer<typeof loginBodySchema>;
@ -57,7 +60,7 @@ export async function login(
success: true,
error: false,
message: "Already logged in",
status: HttpCode.OK,
status: HttpCode.OK
});
}
@ -76,15 +79,9 @@ export async function login(
const existingUser = existingUserRes[0];
const validPassword = await verify(
existingUser.passwordHash,
const validPassword = await verifyPassword(
password,
{
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
}
existingUser.passwordHash
);
if (!validPassword) {
return next(
@ -102,7 +99,7 @@ export async function login(
success: true,
error: false,
message: "Two-factor authentication required",
status: HttpCode.ACCEPTED,
status: HttpCode.ACCEPTED
});
}
@ -137,7 +134,7 @@ export async function login(
success: true,
error: false,
message: "Email verification code sent",
status: HttpCode.OK,
status: HttpCode.OK
});
}
@ -146,7 +143,7 @@ export async function login(
success: true,
error: false,
message: "Logged in successfully",
status: HttpCode.OK,
status: HttpCode.OK
});
} catch (e) {
logger.error(e);

View file

@ -7,16 +7,22 @@ import { response } from "@server/utils";
import { db } from "@server/db";
import { passwordResetTokens, users } from "@server/db/schema";
import { eq } from "drizzle-orm";
import { sha256 } from "oslo/crypto";
import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
import { encodeHex } from "oslo/encoding";
import { createDate } from "oslo";
import logger from "@server/logger";
import { generateIdFromEntropySize } from "@server/auth";
import { TimeSpan } from "oslo";
import config from "@server/config";
import { sendEmail } from "@server/emails";
import ResetPasswordCode from "@server/emails/templates/ResetPasswordCode";
import { hashPassword } from "@server/auth/password";
export const requestPasswordResetBody = z.object({
email: z.string().email(),
}).strict();
export const requestPasswordResetBody = z
.object({
email: z.string().email()
})
.strict();
export type RequestPasswordResetBody = z.infer<typeof requestPasswordResetBody>;
@ -27,7 +33,7 @@ export type RequestPasswordResetResponse = {
export async function requestPasswordReset(
req: Request,
res: Response,
next: NextFunction,
next: NextFunction
): Promise<any> {
const parsedBody = requestPasswordResetBody.safeParse(req.body);
@ -35,8 +41,8 @@ export async function requestPasswordReset(
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString(),
),
fromError(parsedBody.error).toString()
)
);
}
@ -52,8 +58,8 @@ export async function requestPasswordReset(
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"No user with that email exists",
),
"A user with that email does not exist"
)
);
}
@ -61,36 +67,47 @@ export async function requestPasswordReset(
.delete(passwordResetTokens)
.where(eq(passwordResetTokens.userId, existingUser[0].userId));
const token = generateIdFromEntropySize(25);
const tokenHash = encodeHex(
await sha256(new TextEncoder().encode(token)),
);
const token = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
const tokenHash = await hashPassword(token);
await db.insert(passwordResetTokens).values({
userId: existingUser[0].userId,
email: existingUser[0].email,
tokenHash,
expiresAt: createDate(new TimeSpan(2, "h")).getTime(),
expiresAt: createDate(new TimeSpan(2, "h")).getTime()
});
// TODO: send email with link to reset password on dashboard
// something like: https://example.com/auth/reset-password?email=${email}&?token=${token}
// for now, just log the token
const url = `${config.app.base_url}/auth/reset-password?email=${email}&token=${token}`;
await sendEmail(
ResetPasswordCode({
email,
code: token,
link: url
}),
{
from: config.email?.no_reply,
to: email,
subject: "Reset your password"
}
);
return response<RequestPasswordResetResponse>(res, {
data: {
sentEmail: true,
sentEmail: true
},
success: true,
error: false,
message: "Password reset email sent",
status: HttpCode.OK,
message: "Password reset requested",
status: HttpCode.OK
});
} catch (e) {
logger.error(e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to process password reset request",
),
"Failed to process password reset request"
)
);
}
}

View file

@ -13,6 +13,7 @@ import { verify } from "@node-rs/argon2";
import { createTOTPKeyURI } from "oslo/otp";
import config from "@server/config";
import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password";
export const requestTotpSecretBody = z
.object({
@ -47,12 +48,7 @@ export async function requestTotpSecret(
const user = req.user as User;
try {
const validPassword = await verify(user.passwordHash, password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
});
const validPassword = await verifyPassword(password, user.passwordHash);
if (!validPassword) {
return next(unauthorized());
}

View file

@ -1,3 +1,4 @@
import config from "@server/config";
import { Request, Response, NextFunction } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@ -8,19 +9,22 @@ import { db } from "@server/db";
import { passwordResetTokens, users } from "@server/db/schema";
import { eq } from "drizzle-orm";
import { sha256 } from "oslo/crypto";
import { hashPassword } from "@server/auth/password";
import { hashPassword, verifyPassword } from "@server/auth/password";
import { verifyTotpCode } from "@server/auth/2fa";
import { passwordSchema } from "@server/auth/passwordSchema";
import { encodeHex } from "oslo/encoding";
import { isWithinExpirationDate } from "oslo";
import { invalidateAllSessions } from "@server/auth";
import logger from "@server/logger";
import ConfirmPasswordReset from "@server/emails/templates/NotifyResetPassword";
import { sendEmail } from "@server/emails";
export const resetPasswordBody = z
.object({
token: z.string(),
email: z.string().email(),
token: z.string(), // reset secret code
newPassword: passwordSchema,
code: z.string().optional()
code: z.string().optional() // 2fa code
})
.strict();
@ -46,27 +50,28 @@ export async function resetPassword(
);
}
const { token, newPassword, code } = parsedBody.data;
const { token, newPassword, code, email } = parsedBody.data;
try {
const tokenHash = encodeHex(
await sha256(new TextEncoder().encode(token))
);
const resetRequest = await db
.select()
.from(passwordResetTokens)
.where(eq(passwordResetTokens.tokenHash, tokenHash));
.where(eq(passwordResetTokens.email, email));
if (
!resetRequest ||
!resetRequest.length ||
!isWithinExpirationDate(new Date(resetRequest[0].expiresAt))
) {
if (!resetRequest || !resetRequest.length) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid or expired password reset token"
"Invalid password reset token"
)
);
}
if (!isWithinExpirationDate(new Date(resetRequest[0].expiresAt))) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Password reset token has expired"
)
);
}
@ -112,6 +117,20 @@ export async function resetPassword(
}
}
const isTokenValid = await verifyPassword(
token,
resetRequest[0].tokenHash
);
if (!isTokenValid) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid password reset token"
)
);
}
const passwordHash = await hashPassword(newPassword);
await invalidateAllSessions(resetRequest[0].userId);
@ -123,9 +142,13 @@ export async function resetPassword(
await db
.delete(passwordResetTokens)
.where(eq(passwordResetTokens.tokenHash, tokenHash));
.where(eq(passwordResetTokens.email, email));
// TODO: send email to user confirming password reset
await sendEmail(ConfirmPasswordReset({ email }), {
from: config.email?.no_reply,
to: email,
subject: "Password Reset Confirmation"
})
return response<ResetPasswordResponse>(res, {
data: null,

View file

@ -21,6 +21,7 @@ import {
import { ActionsEnum } from "@server/auth/actions";
import config from "@server/config";
import logger from "@server/logger";
import { hashPassword } from "@server/auth/password";
export const signupBodySchema = z.object({
email: z.string().email(),
@ -51,12 +52,7 @@ export async function signup(
const { email, password } = parsedBody.data;
const passwordHash = await hash(password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
const passwordHash = await hashPassword(password);
const userId = generateId(15);
try {

View file

@ -92,6 +92,15 @@ export async function verifyTotp(
// TODO: send email to user confirming two-factor authentication is enabled
if (!valid) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid two-factor authentication code"
)
);
}
return response<VerifyTotpResponse>(res, {
data: {
valid,
@ -118,7 +127,7 @@ export async function verifyTotp(
async function generateBackupCodes(): Promise<string[]> {
const codes = [];
for (let i = 0; i < 10; i++) {
const code = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
const code = generateRandomString(6, alphabet("0-9", "A-Z", "a-z"));
codes.push(code);
}
return codes;

View file

@ -448,11 +448,11 @@ authRouter.post(
verifySessionMiddleware,
auth.requestEmailVerificationCode
);
authRouter.post(
"/change-password",
verifySessionUserMiddleware,
auth.changePassword
);
// authRouter.post(
// "/change-password",
// verifySessionUserMiddleware,
// auth.changePassword
// );
authRouter.post("/reset-password/request", auth.requestPasswordReset);
authRouter.post("/reset-password/", auth.resetPassword);

View file

@ -11,6 +11,7 @@ import moment from "moment";
import { generateSessionToken } from "@server/auth";
import { createNewtSession } from "@server/auth/newt";
import { fromError } from "zod-validation-error";
import { hashPassword } from "@server/auth/password";
export const createNewtBodySchema = z.object({});
@ -54,13 +55,7 @@ export async function createNewt(
);
}
// generate a newtId and secret
const secretHash = await hash(secret, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
const secretHash = await hashPassword(secret);
await db.insert(newts).values({
newtId: newtId,

View file

@ -2,7 +2,7 @@ import { verify } from "@node-rs/argon2";
import {
createSession,
generateSessionToken,
verifySession,
verifySession
} from "@server/auth";
import db from "@server/db";
import { newts } from "@server/db/schema";
@ -14,11 +14,12 @@ import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { createNewtSession, validateNewtSessionToken } from "@server/auth/newt";
import { verifyPassword } from "@server/auth/password";
export const newtGetTokenBodySchema = z.object({
newtId: z.string(),
secret: z.string(),
token: z.string().optional(),
token: z.string().optional()
});
export type NewtGetTokenBody = z.infer<typeof newtGetTokenBodySchema>;
@ -43,16 +44,14 @@ export async function getToken(
try {
if (token) {
const { session, newt } = await validateNewtSessionToken(
token
);
const { session, newt } = await validateNewtSessionToken(token);
if (session) {
return response<null>(res, {
data: null,
success: true,
error: false,
message: "Token session already valid",
status: HttpCode.OK,
status: HttpCode.OK
});
}
}
@ -72,22 +71,13 @@ export async function getToken(
const existingNewt = existingNewtRes[0];
const validSecret = await verify(
existingNewt.secretHash,
const validSecret = await verifyPassword(
secret,
{
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
}
existingNewt.secretHash
);
if (!validSecret) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Secret is incorrect"
)
createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect")
);
}
@ -101,7 +91,7 @@ export async function getToken(
success: true,
error: false,
message: "Token created successfully",
status: HttpCode.OK,
status: HttpCode.OK
});
} catch (e) {
console.error(e);

View file

@ -16,6 +16,7 @@ import config from "@server/config";
import logger from "@server/logger";
import { verify } from "@node-rs/argon2";
import { isWithinExpirationDate } from "oslo";
import { verifyPassword } from "@server/auth/password";
const authWithAccessTokenBodySchema = z
.object({
@ -104,12 +105,8 @@ export async function authWithAccessToken(
);
}
const validCode = await verify(tokenItem.tokenHash, accessToken, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
});
const validCode = await verifyPassword(tokenItem.tokenHash, accessToken);
if (!validCode) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token")

View file

@ -15,6 +15,7 @@ import {
} from "@server/auth/resource";
import config from "@server/config";
import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password";
export const authWithPasswordBodySchema = z
.object({
@ -105,15 +106,9 @@ export async function authWithPassword(
);
}
const validPassword = await verify(
definedPassword.passwordHash,
const validPassword = await verifyPassword(
password,
{
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
}
definedPassword.passwordHash
);
if (!validPassword) {
return next(

View file

@ -23,6 +23,7 @@ import logger from "@server/logger";
import config from "@server/config";
import { AuthWithPasswordResponse } from "./authWithPassword";
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
import { verifyPassword } from "@server/auth/password";
export const authWithPincodeBodySchema = z
.object({
@ -116,12 +117,10 @@ export async function authWithPincode(
);
}
const validPincode = await verify(definedPincode.pincodeHash, pincode, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
});
const validPincode = verifyPassword(
pincode,
definedPincode.pincodeHash
);
if (!validPincode) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN")

View file

@ -9,6 +9,7 @@ import { fromError } from "zod-validation-error";
import { hash } from "@node-rs/argon2";
import { response } from "@server/utils";
import logger from "@server/logger";
import { hashPassword } from "@server/auth/password";
const setResourceAuthMethodsParamsSchema = z.object({
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
@ -57,12 +58,7 @@ export async function setResourcePassword(
.where(eq(resourcePassword.resourceId, resourceId));
if (password) {
const passwordHash = await hash(password, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
});
const passwordHash = await hashPassword(password);
await trx
.insert(resourcePassword)

View file

@ -10,6 +10,7 @@ import { hash } from "@node-rs/argon2";
import { response } from "@server/utils";
import stoi from "@server/utils/stoi";
import logger from "@server/logger";
import { hashPassword } from "@server/auth/password";
const setResourceAuthMethodsParamsSchema = z.object({
resourceId: z.string().transform(Number).pipe(z.number().int().positive()),
@ -61,12 +62,7 @@ export async function setResourcePincode(
.where(eq(resourcePincode.resourceId, resourceId));
if (pincode) {
const pincodeHash = await hash(pincode, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1,
});
const pincodeHash = await hashPassword(pincode);
await trx
.insert(resourcePincode)

View file

@ -13,6 +13,7 @@ import { fromError } from "zod-validation-error";
import { hash } from "@node-rs/argon2";
import { newts } from "@server/db/schema";
import moment from "moment";
import { hashPassword } from "@server/auth/password";
const createSiteParamsSchema = z
.object({
@ -122,12 +123,7 @@ export async function createSite(
// add the peer to the exit node
if (type == "newt") {
const secretHash = await hash(secret!, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
});
const secretHash = await hashPassword(secret!);
await db.insert(newts).values({
newtId: newtId!,

View file

@ -10,6 +10,7 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { isWithinExpirationDate } from "oslo";
import { verifyPassword } from "@server/auth/password";
const acceptInviteBodySchema = z
.object({
@ -62,12 +63,10 @@ export async function acceptInvite(
);
}
const validToken = await verify(existingInvite[0].tokenHash, token, {
memoryCost: 19456,
timeCost: 2,
outputLen: 32,
parallelism: 1
});
const validToken = await verifyPassword(
token,
existingInvite[0].tokenHash
);
if (!validToken) {
return next(
createHttpError(

View file

@ -30,8 +30,8 @@ export default async function OrgLayout(props: {
const getOrgUser = cache(() =>
internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${orgId}/user/${user.userId}`,
cookie,
),
cookie
)
);
const orgUser = await getOrgUser();
} catch {
@ -40,10 +40,7 @@ export default async function OrgLayout(props: {
try {
const getOrg = cache(() =>
internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${orgId}`,
cookie,
),
internal.get<AxiosResponse<GetOrgResponse>>(`/org/${orgId}`, cookie)
);
await getOrg();
} catch {

View file

@ -126,7 +126,7 @@ export default function CreateRoleForm({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
className="space-y-4"
id="create-role-form"
>
<FormField

View file

@ -173,7 +173,7 @@ export default function DeleteRoleForm({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
className="space-y-4"
id="remove-role-form"
>
<FormField

View file

@ -123,7 +123,7 @@ export default function AccessControlsPage() {
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
className="space-y-4"
>
<FormField
control={form.control}

View file

@ -1,221 +0,0 @@
"use client";
import { createApiClient } from "@app/api";
import { Avatar, AvatarFallback } from "@app/components/ui/avatar";
import { Button } from "@app/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@app/components/ui/command";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@app/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@app/components/ui/popover";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@app/components/ui/select";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useToast } from "@app/hooks/useToast";
import { cn, formatAxiosError } from "@app/lib/utils";
import { ListOrgsResponse } from "@server/routers/org";
import { Check, ChevronsUpDown, Plus } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
type HeaderProps = {
name?: string;
email: string;
orgId: string;
orgs: ListOrgsResponse["orgs"];
};
export default function Header({ email, orgId, name, orgs }: HeaderProps) {
const { toast } = useToast();
const [open, setOpen] = useState(false);
const router = useRouter();
const api = createApiClient(useEnvContext());
function getInitials() {
if (name) {
const [firstName, lastName] = name.split(" ");
return `${firstName[0]}${lastName[0]}`;
}
return email.substring(0, 2).toUpperCase();
}
function logout() {
api.post("/auth/logout")
.catch((e) => {
console.error("Error logging out", e);
toast({
title: "Error logging out",
description: formatAxiosError(e, "Error logging out"),
});
})
.then(() => {
router.push("/auth/login");
});
}
return (
<>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="relative h-10 w-10 rounded-full"
>
<Avatar className="h-9 w-9">
<AvatarFallback>
{getInitials()}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-56"
align="start"
forceMount
>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
{name && (
<p className="text-sm font-medium leading-none truncate">
{name}
</p>
)}
<p className="text-xs leading-none text-muted-foreground truncate">
{email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={logout}>
Logout
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<span className="truncate max-w-[150px] md:max-w-none font-medium">
{name || email}
</span>
</div>
<div className="flex items-center">
<div className="hidden md:block">
<div className="flex items-center gap-4 mr-4">
<Link
href="/docs"
className="text-muted-foreground hover:text-foreground"
>
Documentation
</Link>
<Link
href="/support"
className="text-muted-foreground hover:text-foreground"
>
Support
</Link>
</div>
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="lg"
role="combobox"
aria-expanded={open}
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">
<span className="font-bold text-sm">
Organization
</span>
<span className="text-sm text-muted-foreground">
{orgId
? orgs.find(
(org) =>
org.orgId === orgId,
)?.name
: "Select organization..."}
</span>
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="[100px] md:w-[180px] p-0">
<Command>
<CommandInput placeholder="Search..." />
<CommandEmpty>
No organization found.
</CommandEmpty>
<CommandGroup className="[50px]">
<CommandList>
<CommandItem
className="flex items-center border border-input mb-2 cursor-pointer"
onSelect={(currentValue) => {
router.push("/setup");
}}
>
<Plus className="mr-2 h-4 w-4"/>
New Organization
</CommandItem>
{orgs.map((org) => (
<CommandItem
key={org.orgId}
onSelect={(currentValue) => {
router.push(
`/${org.orgId}/settings`,
);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
orgId === org.orgId
? "opacity-100"
: "opacity-0",
)}
/>
{org.name}
</CommandItem>
))}
</CommandList>
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
</>
);
}

View file

@ -57,12 +57,11 @@ export default function GeneralPage() {
async function deleteOrg() {
try {
const res = await api
.delete<AxiosResponse<DeleteOrgResponse>>(`/org/${org?.org.orgId}`);
const res = await api.delete<AxiosResponse<DeleteOrgResponse>>(
`/org/${org?.org.orgId}`
);
if (res.status === 200) {
console.log("Org deleted");
}
} catch (err) {
console.error(err);
@ -72,7 +71,7 @@ export default function GeneralPage() {
description: formatAxiosError(
err,
"An error occurred while deleting the org."
),
)
});
}
}
@ -118,16 +117,17 @@ export default function GeneralPage() {
</p>
</div>
}
buttonText="Confirm delete organization"
buttonText="Confirm Delete Organization"
onConfirm={deleteOrg}
string={org?.org.name || ""}
title="Delete organization"
title="Delete Organization"
/>
<section className="space-y-8 max-w-lg">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8 max-w-lg"
className="space-y-4"
>
<FormField
control={form.control}
@ -149,7 +149,7 @@ export default function GeneralPage() {
</form>
</Form>
<Card className="max-w-lg border-red-900 mt-5">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="h-5 w-5" />
@ -157,9 +157,9 @@ export default function GeneralPage() {
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm mb-4">
Once you delete this org, there is no going back. Please
be certain.
<p className="text-sm">
Once you delete this org, there is no going back.
Please be certain.
</p>
</CardContent>
<CardFooter className="flex justify-end gap-2">
@ -169,10 +169,11 @@ export default function GeneralPage() {
className="flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
Delete
Delete Organization Data
</Button>
</CardFooter>
</Card>
</section>
</>
);
}

View file

@ -1,7 +1,7 @@
import { Metadata } from "next";
import { TopbarNav } from "./components/TopbarNav";
import { TopbarNav } from "@app/components/TopbarNav";
import { Cog, Combine, Link, Settings, Users, Waypoints } from "lucide-react";
import Header from "./components/Header";
import { Header } from "@app/components/Header";
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { internal } from "@app/api";
@ -10,6 +10,7 @@ import { GetOrgResponse, ListOrgsResponse } from "@server/routers/org";
import { authCookieHeader } from "@app/api/cookies";
import { cache } from "react";
import { GetOrgUserResponse } from "@server/routers/user";
import UserProvider from "@app/providers/UserProvider";
export const dynamic = "force-dynamic";
@ -99,38 +100,17 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
<div className="w-full border-b bg-neutral-100 dark:bg-neutral-800 select-none sm:px-0 px-3 fixed top-0 z-10">
<div className="container mx-auto flex flex-col content-between">
<div className="my-4">
<Header
email={user.email}
orgId={params.orgId}
orgs={orgs}
/>
<UserProvider user={user}>
<Header orgId={params.orgId} orgs={orgs} />
</UserProvider>
</div>
<TopbarNav items={topNavItems} orgId={params.orgId} />
</div>
</div>
<div className="container mx-auto sm:px-0 px-3 pt-[165px]">{children}</div>
<footer className="w-full mt-6 py-3">
<div className="container mx-auto flex justify-end items-center px-3 sm:px-0 text-sm text-neutral-300 dark:text-neutral-700 space-x-3 select-none">
<div>Built by Fossorial</div>
<a
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4"
>
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
</svg>
</a>
<div className="container mx-auto sm:px-0 px-3 pt-[165px]">
{children}
</div>
</footer>
</>
);
}

View file

@ -412,7 +412,7 @@ export default function ResourceAuthenticationPage() {
onSubmit={usersRolesForm.handleSubmit(
onSubmitUsersRoles
)}
className="space-y-8"
className="space-y-4"
>
<FormField
control={usersRolesForm.control}
@ -639,7 +639,7 @@ export default function ResourceAuthenticationPage() {
{whitelistEnabled && (
<Form {...whitelistForm}>
<form className="space-y-8">
<form className="space-y-4">
<FormField
control={whitelistForm.control}
name="emails"

View file

@ -439,7 +439,7 @@ export default function ReverseProxyTargets(props: {
onSubmit={addTargetForm.handleSubmit(
addTarget as any,
)}
className="space-y-8"
className="space-y-4"
>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<FormField

View file

@ -135,7 +135,7 @@ export default function GeneralForm() {
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
className="space-y-4"
>
<FormField
control={form.control}

View file

@ -63,6 +63,7 @@ import { Checkbox } from "@app/components/ui/checkbox";
import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
import { constructShareLink } from "@app/lib/shareLinks";
import { ShareLinkRow } from "./ShareLinksTable";
import { QRCodeSVG } from "qrcode.react";
type FormProps = {
open: boolean;
@ -226,13 +227,13 @@ export default function CreateShareLinkForm({
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Create Sharable Link</CredenzaTitle>
<CredenzaTitle>Create Shareable Link</CredenzaTitle>
<CredenzaDescription>
Anyone with this link can access the resource
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-8">
<div className="space-y-4">
{!link && (
<Form {...form}>
<form
@ -436,10 +437,10 @@ export default function CreateShareLinkForm({
Expiration time is how long the
link will be usable and provide
access to the resource. After
this time, the link will expire
and no longer work, and users
who used this link will lose
access to the resource.
this time, the link will no
longer work, and users who used
this link will lose access to
the resource.
</p>
</div>
</form>
@ -448,15 +449,25 @@ export default function CreateShareLinkForm({
{link && (
<div className="max-w-md space-y-4">
<p>
You will be able to see this link once.
You will only be able to see this link once.
Make sure to copy it.
</p>
<p>
Anyone with this link can access the
resource. Share it with care.
</p>
<div className="w-64 h-64 mx-auto flex items-center justify-center">
<QRCodeSVG
value={link}
size={256}
/>
</div>
<div className="mx-auto">
<CopyTextBox text={link} wrapText={false} />
</div>
</div>
)}
</div>
</CredenzaBody>

View file

@ -77,7 +77,7 @@ export default function GeneralPage() {
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
className="space-y-4"
>
<FormField
control={form.control}

View file

@ -203,7 +203,7 @@ PersistentKeepalive = 5`
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
className="space-y-4"
id="create-site-form"
>
<FormField

View file

@ -195,14 +195,14 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
if (originalRow.online) {
return (
<span className="text-green-500 flex items-center space-x-2">
<Check className="w-4 h-4" />
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span>Online</span>
</span>
);
} else {
return (
<span className="text-red-500 flex items-center space-x-2">
<X className="w-4 h-4" />
<span className="text-gray-500 flex items-center space-x-2">
<div className="w-2 h-2 bg-gray-500 rounded-full"></div>
<span>Offline</span>
</span>
);

View file

@ -0,0 +1,472 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot
} from "@/components/ui/input-otp";
import { AxiosResponse } from "axios";
import {
RequestPasswordResetBody,
RequestPasswordResetResponse,
resetPasswordBody,
ResetPasswordBody,
ResetPasswordResponse
} from "@server/routers/auth";
import { Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "../../../components/ui/alert";
import { useToast } from "@app/hooks/useToast";
import { useRouter } from "next/navigation";
import { formatAxiosError } from "@app/lib/utils";
import { createApiClient } from "@app/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { passwordSchema } from "@server/auth/passwordSchema";
import { get } from "http";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
const requestSchema = z.object({
email: z.string().email()
});
const formSchema = z
.object({
email: z.string().email({ message: "Invalid email address" }),
token: z.string().min(8, { message: "Invalid token" }),
password: passwordSchema,
confirmPassword: passwordSchema
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: "Passwords do not match"
});
const mfaSchema = z.object({
code: z.string().length(6, { message: "Invalid code" })
});
export type ResetPasswordFormProps = {
emailParam?: string;
tokenParam?: string;
redirect?: string;
};
export default function ResetPasswordForm({
emailParam,
tokenParam,
redirect
}: ResetPasswordFormProps) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
function getState() {
if (emailParam && !tokenParam) {
return "request";
}
if (emailParam && tokenParam) {
return "reset";
}
return "request";
}
const [state, setState] = useState<"request" | "reset" | "mfa">(getState());
const { toast } = useToast();
const api = createApiClient(useEnvContext());
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: emailParam || "",
token: tokenParam || "",
password: "",
confirmPassword: ""
}
});
const mfaForm = useForm<z.infer<typeof mfaSchema>>({
resolver: zodResolver(mfaSchema),
defaultValues: {
code: ""
}
});
const requestForm = useForm<z.infer<typeof requestSchema>>({
resolver: zodResolver(requestSchema),
defaultValues: {
email: emailParam || ""
}
});
async function onRequest(data: z.infer<typeof requestSchema>) {
const { email } = data;
setIsSubmitting(true);
const res = await api
.post<AxiosResponse<RequestPasswordResetResponse>>(
"/auth/reset-password/request",
{
email
} as RequestPasswordResetBody
)
.catch((e) => {
setError(formatAxiosError(e, "An error occurred"));
console.error("Failed to request reset:", e);
setIsSubmitting(false);
});
if (res && res.data?.data) {
setError(null);
setState("reset");
setIsSubmitting(false);
form.setValue("email", email);
}
}
async function onReset(data: any) {
setIsSubmitting(true);
const { password, email, token } = form.getValues();
const { code } = mfaForm.getValues();
const res = await api
.post<AxiosResponse<ResetPasswordResponse>>(
"/auth/reset-password",
{
email,
token,
newPassword: password,
code
} as ResetPasswordBody
)
.catch((e) => {
setError(formatAxiosError(e, "An error occurred"));
console.error("Failed to reset password:", e);
setIsSubmitting(false);
});
console.log(res);
if (res) {
setError(null);
if (res.data.data?.codeRequested) {
setState("mfa");
setIsSubmitting(false);
mfaForm.reset();
return;
}
setSuccessMessage("Password reset successfully! Back to login...");
setTimeout(() => {
if (redirect && redirect.includes("http")) {
window.location.href = redirect;
}
if (redirect) {
router.push(redirect);
} else {
router.push("/login");
}
setIsSubmitting(false);
}, 1500);
}
}
return (
<div>
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Reset Password</CardTitle>
<CardDescription>
Follow the steps to reset your password
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{state === "request" && (
<Form {...requestForm}>
<form
onSubmit={requestForm.handleSubmit(
onRequest
)}
className="space-y-4"
id="form"
>
<FormField
control={requestForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Enter your email"
{...field}
/>
</FormControl>
<FormDescription>
We'll send a password reset
code to this email address.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
{state === "reset" && (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onReset)}
className="space-y-4"
id="form"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
placeholder="Email"
{...field}
disabled
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!tokenParam && (
<FormField
control={form.control}
name="token"
render={({ field }) => (
<FormItem>
<FormLabel>
Reset Code
</FormLabel>
<FormControl>
<Input
placeholder="Enter reset code sent to your email"
type="password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>
New Password
</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>
Confirm New Password
</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Confirm Password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
{state === "mfa" && (
<Form {...mfaForm}>
<form
onSubmit={mfaForm.handleSubmit(onReset)}
className="space-y-4"
id="form"
>
<FormField
control={mfaForm.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>
Authenticator Code
</FormLabel>
<FormControl>
<div className="flex justify-center">
<InputOTP
maxLength={6}
{...field}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
>
<InputOTPGroup>
<InputOTPSlot
index={0}
/>
<InputOTPSlot
index={1}
/>
<InputOTPSlot
index={2}
/>
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot
index={3}
/>
<InputOTPSlot
index={4}
/>
<InputOTPSlot
index={5}
/>
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)}
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{successMessage && (
<Alert variant="success">
<AlertDescription>
{successMessage}
</AlertDescription>
</Alert>
)}
<div className="space-y-4">
{(state === "reset" || state === "mfa") && (
<Button
type="submit"
form="form"
className="w-full"
disabled={isSubmitting}
>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{state === "reset"
? "Reset Password"
: "Submit Code"}
</Button>
)}
{state === "request" && (
<Button
type="submit"
form="form"
className="w-full"
disabled={isSubmitting}
>
{isSubmitting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Request Reset
</Button>
)}
{state === "mfa" && (
<Button
type="button"
className="w-full"
variant="outline"
onClick={() => {
setState("reset");
mfaForm.reset();
}}
>
Back to Password
</Button>
)}
{(state === "mfa" || state === "reset") && (
<Button
type="button"
className="w-full"
variant="outline"
onClick={() => {
setState("request");
form.reset();
}}
>
Back to Email
</Button>
)}
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,46 @@
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { cache } from "react";
import ResetPasswordForm from "./ResetPasswordForm";
import Link from "next/link";
export const dynamic = "force-dynamic";
export default async function Page(props: {
searchParams: Promise<{
redirect: string | undefined;
email: string | undefined;
token: string | undefined;
}>;
}) {
const searchParams = await props.searchParams;
const getUser = cache(verifySession);
const user = await getUser();
if (user) {
redirect("/");
}
return (
<>
<ResetPasswordForm
redirect={searchParams.redirect}
tokenParam={searchParams.token}
emailParam={searchParams.email}
/>
<p className="text-center text-muted-foreground mt-4">
<Link
href={
!searchParams.redirect
? `/auth/signup`
: `/auth/signup?redirect=${searchParams.redirect}`
}
className="underline"
>
Go to login
</Link>
</p>
</>
);
}

View file

@ -114,7 +114,7 @@ export default function SignupForm({ redirect }: SignupFormProps) {
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
className="space-y-4"
>
<FormField
control={form.control}

View file

@ -138,7 +138,7 @@ export default function VerifyEmailForm({
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-8"
className="space-y-4"
>
<FormField
control={form.control}

View file

@ -4,6 +4,7 @@ import { Figtree } from "next/font/google";
import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@app/providers/ThemeProvider";
import EnvProvider from "@app/providers/EnvProvider";
import { Separator } from "@app/components/ui/separator";
export const metadata: Metadata = {
title: `Dashboard - Pangolin`,
@ -17,6 +18,8 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const version = process.env.APP_VERSION;
return (
<html suppressHydrationWarning>
<body className={`${font.className}`}>
@ -38,6 +41,37 @@ export default async function RootLayout({
}}
>
{children}
<footer className="w-full mt-6 py-3">
<div className="container mx-auto flex justify-center items-center h-5 space-x-4 text-sm text-neutral-400 select-none">
<div>Built by Fossorial</div>
<Separator orientation="vertical" />
<div className="flex items-center space-x-3">
<div>Open Source</div>
<a
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4"
>
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
</svg>
</a>
</div>
{version && (
<>
<Separator orientation="vertical" />
<div>v{version}</div>
</>
)}
</div>
</footer>
</EnvProvider>
<Toaster />
</ThemeProvider>

View file

@ -1,176 +0,0 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { CalendarIcon, CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { cn } from "@/lib/utils"
import { toast } from "@/hooks/useToast"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
const languages = [
{ label: "English", value: "en" },
{ label: "French", value: "fr" },
{ label: "German", value: "de" },
{ label: "Spanish", value: "es" },
{ label: "Portuguese", value: "pt" },
{ label: "Russian", value: "ru" },
{ label: "Japanese", value: "ja" },
{ label: "Korean", value: "ko" },
{ label: "Chinese", value: "zh" },
] as const
const accountFormSchema = z.object({
name: z
.string()
.min(2, {
message: "Name must be at least 2 characters.",
})
.max(30, {
message: "Name must not be longer than 30 characters.",
}),
dob: z.date({
required_error: "A date of birth is required.",
}),
language: z.string({
required_error: "Please select a language.",
}),
})
type AccountFormValues = z.infer<typeof accountFormSchema>
// This can come from your database or API.
const defaultValues: Partial<AccountFormValues> = {
// name: "Your name",
// dob: new Date("2023-01-23"),
}
export function AccountForm() {
const form = useForm<AccountFormValues>({
resolver: zodResolver(accountFormSchema),
defaultValues,
})
function onSubmit(data: AccountFormValues) {
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
),
})
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Your name" {...field} />
</FormControl>
<FormDescription>
This is the name that will be displayed on your profile and in
emails.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Language</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-[200px] justify-between",
!field.value && "text-muted-foreground"
)}
>
{field.value
? languages.find(
(language) => language.value === field.value
)?.label
: "Select language"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search language..." />
<CommandList>
<CommandEmpty>No language found.</CommandEmpty>
<CommandGroup>
{languages.map((language) => (
<CommandItem
value={language.label}
key={language.value}
onSelect={() => {
form.setValue("language", language.value)
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
language.value === field.value
? "opacity-100"
: "opacity-0"
)}
/>
{language.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
This is the language that will be used in the dashboard.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Update account</Button>
</form>
</Form>
)
}

View file

@ -1,18 +0,0 @@
import { Separator } from "@/components/ui/separator"
import { AccountForm } from "./account-form"
export default function SettingsAccountPage() {
return (
<div className="space-y-8">
<div>
<h3 className="text-lg font-medium">Account</h3>
<p className="text-sm text-muted-foreground">
Update your account settings. Set your preferred language and
timezone.
</p>
</div>
<Separator />
<AccountForm />
</div>
)
}

View file

@ -1,164 +0,0 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { ChevronDownIcon } from "@radix-ui/react-icons"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { cn } from "@/lib/utils"
import { toast } from "@/hooks/useToast"
import { Button, buttonVariants } from "@/components/ui/button"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
const appearanceFormSchema = z.object({
theme: z.enum(["light", "dark"], {
required_error: "Please select a theme.",
}),
font: z.enum(["inter", "manrope", "system"], {
invalid_type_error: "Select a font",
required_error: "Please select a font.",
}),
})
type AppearanceFormValues = z.infer<typeof appearanceFormSchema>
// This can come from your database or API.
const defaultValues: Partial<AppearanceFormValues> = {
theme: "light",
}
export function AppearanceForm() {
const form = useForm<AppearanceFormValues>({
resolver: zodResolver(appearanceFormSchema),
defaultValues,
})
function onSubmit(data: AppearanceFormValues) {
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
),
})
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="font"
render={({ field }) => (
<FormItem>
<FormLabel>Font</FormLabel>
<div className="relative w-max">
<FormControl>
<select
className={cn(
buttonVariants({ variant: "outline" }),
"w-[200px] appearance-none font-normal"
)}
{...field}
>
<option value="inter">Inter</option>
<option value="manrope">Manrope</option>
<option value="system">System</option>
</select>
</FormControl>
<ChevronDownIcon className="absolute right-3 top-2.5 h-4 w-4 opacity-50" />
</div>
<FormDescription>
Set the font you want to use in the dashboard.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="theme"
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel>Theme</FormLabel>
<FormDescription>
Select the theme for the dashboard.
</FormDescription>
<FormMessage />
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="grid max-w-md grid-cols-2 gap-8 pt-2"
>
<FormItem>
<FormLabel className="[&:has([data-state=checked])>div]:border-primary">
<FormControl>
<RadioGroupItem value="light" className="sr-only" />
</FormControl>
<div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent">
<div className="space-y-2 rounded-sm bg-[#ecedef] p-2">
<div className="space-y-2 rounded-md bg-white p-2 shadow-sm">
<div className="h-2 w-[80px] rounded-lg bg-[#ecedef]" />
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
</div>
</div>
</div>
<span className="block w-full p-2 text-center font-normal">
Light
</span>
</FormLabel>
</FormItem>
<FormItem>
<FormLabel className="[&:has([data-state=checked])>div]:border-primary">
<FormControl>
<RadioGroupItem value="dark" className="sr-only" />
</FormControl>
<div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
<div className="space-y-2 rounded-sm bg-slate-950 p-2">
<div className="space-y-2 rounded-md bg-slate-800 p-2 shadow-sm">
<div className="h-2 w-[80px] rounded-lg bg-slate-400" />
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-slate-400" />
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-slate-400" />
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
</div>
</div>
</div>
<span className="block w-full p-2 text-center font-normal">
Dark
</span>
</FormLabel>
</FormItem>
</RadioGroup>
</FormItem>
)}
/>
<Button type="submit">Update preferences</Button>
</form>
</Form>
)
}

View file

@ -1,18 +0,0 @@
import { Separator } from "@/components/ui/separator"
import { AppearanceForm } from "./appearance-form"
export default function SettingsAppearancePage() {
return (
<div className="space-y-8">
<div>
<h3 className="text-lg font-medium">Appearance</h3>
<p className="text-sm text-muted-foreground">
Customize the appearance of the app. Automatically switch between day
and night themes.
</p>
</div>
<Separator />
<AppearanceForm />
</div>
)
}

View file

@ -1,132 +0,0 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { toast } from "@/hooks/useToast"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
const items = [
{
id: "recents",
label: "Recents",
},
{
id: "home",
label: "Home",
},
{
id: "applications",
label: "Applications",
},
{
id: "desktop",
label: "Desktop",
},
{
id: "downloads",
label: "Downloads",
},
{
id: "documents",
label: "Documents",
},
] as const
const displayFormSchema = z.object({
items: z.array(z.string()).refine((value) => value.some((item) => item), {
message: "You have to select at least one item.",
}),
})
type DisplayFormValues = z.infer<typeof displayFormSchema>
// This can come from your database or API.
const defaultValues: Partial<DisplayFormValues> = {
items: ["recents", "home"],
}
export function DisplayForm() {
const form = useForm<DisplayFormValues>({
resolver: zodResolver(displayFormSchema),
defaultValues,
})
function onSubmit(data: DisplayFormValues) {
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
),
})
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="items"
render={() => (
<FormItem>
<div className="mb-4">
<FormLabel className="text-base">Sidebar</FormLabel>
<FormDescription>
Select the items you want to display in the sidebar.
</FormDescription>
</div>
{items.map((item) => (
<FormField
key={item.id}
control={form.control}
name="items"
render={({ field }) => {
return (
<FormItem
key={item.id}
className="flex flex-row items-start space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={field.value?.includes(item.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, item.id])
: field.onChange(
field.value?.filter(
(value) => value !== item.id
)
)
}}
/>
</FormControl>
<FormLabel className="font-normal">
{item.label}
</FormLabel>
</FormItem>
)
}}
/>
))}
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Update display</Button>
</form>
</Form>
)
}

View file

@ -1,17 +0,0 @@
import { Separator } from "@/components/ui/separator"
import { DisplayForm } from "./display-form"
export default function SettingsDisplayPage() {
return (
<div className="space-y-8">
<div>
<h3 className="text-lg font-medium">Display</h3>
<p className="text-sm text-muted-foreground">
Turn items on or off to control what&apos;s displayed in the app.
</p>
</div>
<Separator />
<DisplayForm />
</div>
)
}

View file

@ -0,0 +1,36 @@
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { SidebarSettings } from "@app/components/SidebarSettings";
import { verifySession } from "@app/lib/auth/verifySession";
import UserProvider from "@app/providers/UserProvider";
import { redirect } from "next/navigation";
import { cache } from "react";
type ProfileGeneralProps = {
children: React.ReactNode;
};
export default async function GeneralSettingsPage({
children
}: ProfileGeneralProps) {
const getUser = cache(verifySession);
const user = await getUser();
if (!user) {
redirect(`/?redirect=/profile/general`);
}
const sidebarNavItems = [
{
title: "Authentication",
href: `/{orgId}/settings/general`
}
];
return (
<>
<UserProvider user={user}>
{children}
</UserProvider>
</>
);
}

View file

@ -0,0 +1,14 @@
"use client";
import { useState } from "react";
import Enable2FaForm from "./components/Enable2FaForm";
export default function ProfileGeneralPage() {
const [open, setOpen] = useState(true);
return (
<>
<Enable2FaForm open={open} setOpen={setOpen} />
</>
);
}

View file

@ -1,76 +0,0 @@
import { Metadata } from "next"
import Image from "next/image"
import { Separator } from "@/components/ui/separator"
import { SidebarNav } from "@/components/sidebar-nav"
import Header from "../[orgId]/settings/components/Header"
export const metadata: Metadata = {
title: "Forms",
description: "Advanced form example using react-hook-form and Zod.",
}
const sidebarNavItems = [
{
title: "Profile",
href: "/configuration",
},
{
title: "Account",
href: "/configuration/account",
},
{
title: "Appearance",
href: "/configuration/appearance",
},
{
title: "Notifications",
href: "/configuration/notifications",
},
{
title: "Display",
href: "/configuration/display",
},
]
interface SettingsLayoutProps {
children: React.ReactNode
}
export default function SettingsLayout({ children }: SettingsLayoutProps) {
return (
<>
<div className="md:hidden">
<Image
src="/configuration/forms-light.png"
width={1280}
height={791}
alt="Forms"
className="block dark:hidden"
/>
<Image
src="/configuration/forms-dark.png"
width={1280}
height={791}
alt="Forms"
className="hidden dark:block"
/>
</div>
<div className="hidden space-y-8 p-10 pb-16 md:block">
<div className="space-y-0.5">
<h2 className="text-2xl font-bold tracking-tight">Settings</h2>
<p className="text-muted-foreground">
Manage your account settings and set e-mail preferences.
</p>
</div>
<Separator className="my-6" />
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-12 lg:space-y-0">
<aside className="-mx-4 lg:w-1/5">
<SidebarNav items={sidebarNavItems} />
</aside>
<div className="flex-1 lg:max-w-2xl">{children}</div>
</div>
</div>
</>
)
}

View file

@ -0,0 +1,74 @@
import { Metadata } from "next";
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
import { cache } from "react";
import Header from "@app/components/Header";
import { internal } from "@app/api";
import { AxiosResponse } from "axios";
import { ListOrgsResponse } from "@server/routers/org";
import { authCookieHeader } from "@app/api/cookies";
import { TopbarNav } from "@app/components/TopbarNav";
import { Settings } from "lucide-react";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: `User Settings - Pangolin`,
description: ""
};
const topNavItems = [
{
title: "User Settings",
href: "/profile/general",
icon: <Settings className="h-4 w-4" />
}
];
interface SettingsLayoutProps {
children: React.ReactNode;
params: Promise<{}>;
}
export default async function SettingsLayout(props: SettingsLayoutProps) {
const { children } = props;
const getUser = cache(verifySession);
const user = await getUser();
if (!user) {
redirect(`/`);
}
const cookie = await authCookieHeader();
let orgs: ListOrgsResponse["orgs"] = [];
try {
const getOrgs = cache(() =>
internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie)
);
const res = await getOrgs();
if (res && res.data.data.orgs) {
orgs = res.data.data.orgs;
}
} catch (e) {
console.error("Error fetching orgs", e);
}
return (
<>
<div className="w-full border-b bg-neutral-100 dark:bg-neutral-800 select-none sm:px-0 px-3 fixed top-0 z-10">
<div className="container mx-auto flex flex-col content-between">
<div className="my-4">
<Header email={user.email} orgs={orgs} />
</div>
<TopbarNav items={topNavItems} />
</div>
</div>
<div className="container mx-auto sm:px-0 px-3 pt-[165px]">
{children}
</div>
</>
);
}

View file

@ -1,222 +0,0 @@
"use client"
import Link from "next/link"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { toast } from "@/hooks/useToast"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Switch } from "@/components/ui/switch"
const notificationsFormSchema = z.object({
type: z.enum(["all", "mentions", "none"], {
required_error: "You need to select a notification type.",
}),
mobile: z.boolean().default(false).optional(),
communication_emails: z.boolean().default(false).optional(),
social_emails: z.boolean().default(false).optional(),
marketing_emails: z.boolean().default(false).optional(),
security_emails: z.boolean(),
})
type NotificationsFormValues = z.infer<typeof notificationsFormSchema>
// This can come from your database or API.
const defaultValues: Partial<NotificationsFormValues> = {
communication_emails: false,
marketing_emails: false,
social_emails: true,
security_emails: true,
}
export function NotificationsForm() {
const form = useForm<NotificationsFormValues>({
resolver: zodResolver(notificationsFormSchema),
defaultValues,
})
function onSubmit(data: NotificationsFormValues) {
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
),
})
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>Notify me about...</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex flex-col space-y-1"
>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="all" />
</FormControl>
<FormLabel className="font-normal">
All new messages
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="mentions" />
</FormControl>
<FormLabel className="font-normal">
Direct messages and mentions
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="none" />
</FormControl>
<FormLabel className="font-normal">Nothing</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<h3 className="mb-4 text-lg font-medium">Email Notifications</h3>
<div className="space-y-4">
<FormField
control={form.control}
name="communication_emails"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Communication emails
</FormLabel>
<FormDescription>
Receive emails about your account activity.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="marketing_emails"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Marketing emails
</FormLabel>
<FormDescription>
Receive emails about new products, features, and more.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="social_emails"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Social emails</FormLabel>
<FormDescription>
Receive emails for friend requests, follows, and more.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="security_emails"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Security emails</FormLabel>
<FormDescription>
Receive emails about your account activity and security.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled
aria-readonly
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
<FormField
control={form.control}
name="mobile"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
Use different settings for my mobile devices
</FormLabel>
<FormDescription>
You can manage your mobile notifications in the{" "}
<Link href="/examples/forms">mobile settings</Link> page.
</FormDescription>
</div>
</FormItem>
)}
/>
<Button type="submit">Update notifications</Button>
</form>
</Form>
)
}

View file

@ -1,17 +0,0 @@
import { Separator } from "@/components/ui/separator"
import { NotificationsForm } from "./notifications-form"
export default function SettingsNotificationsPage() {
return (
<div className="space-y-8">
<div>
<h3 className="text-lg font-medium">Notifications</h3>
<p className="text-sm text-muted-foreground">
Configure how you receive notifications.
</p>
</div>
<Separator />
<NotificationsForm />
</div>
)
}

View file

@ -1,17 +0,0 @@
import { Separator } from "@/components/ui/separator"
import { ProfileForm } from "@app/components/profile-form"
export default function SettingsProfilePage() {
return (
<div className="space-y-8">
<div>
<h3 className="text-lg font-medium">Profile</h3>
<p className="text-sm text-muted-foreground">
This is how others will see you on the site.
</p>
</div>
<Separator />
<ProfileForm />
</div>
)
}

View file

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default async function ProfilePage() {
redirect("/profile/general");
}

View file

@ -1,192 +0,0 @@
"use client"
import Link from "next/link"
import { zodResolver } from "@hookform/resolvers/zod"
import { useFieldArray, useForm } from "react-hook-form"
import { z } from "zod"
import { cn } from "@/lib/utils"
import { toast } from "@/hooks/useToast"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
const profileFormSchema = z.object({
username: z
.string()
.min(2, {
message: "Username must be at least 2 characters.",
})
.max(30, {
message: "Username must not be longer than 30 characters.",
}),
email: z
.string({
required_error: "Please select an email to display.",
})
.email(),
bio: z.string().max(160).min(4),
urls: z
.array(
z.object({
value: z.string().url({ message: "Please enter a valid URL." }),
})
)
.optional(),
})
type ProfileFormValues = z.infer<typeof profileFormSchema>
// This can come from your database or API.
const defaultValues: Partial<ProfileFormValues> = {
bio: "I own a computer.",
urls: [
{ value: "https://shadcn.com" },
{ value: "http://twitter.com/shadcn" },
],
}
export function ProfileForm() {
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileFormSchema),
defaultValues,
mode: "onChange",
})
const { fields, append } = useFieldArray({
name: "urls",
control: form.control,
})
function onSubmit(data: ProfileFormValues) {
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
),
})
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>
This is your public display name. It can be your real name or a
pseudonym. You can only change this once every 30 days.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a verified email to display" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="m@example.com">m@example.com</SelectItem>
<SelectItem value="m@google.com">m@google.com</SelectItem>
<SelectItem value="m@support.com">m@support.com</SelectItem>
</SelectContent>
</Select>
<FormDescription>
You can manage verified email addresses in your{" "}
<Link href="/examples/forms">email settings</Link>.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea
placeholder="Tell us a little bit about yourself"
className="resize-none"
{...field}
/>
</FormControl>
<FormDescription>
You can <span>@mention</span> other users and organizations to
link to them.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
{fields.map((field, index) => (
<FormField
control={form.control}
key={field.id}
name={`urls.${index}.value`}
render={({ field }) => (
<FormItem>
<FormLabel className={cn(index !== 0 && "sr-only")}>
URLs
</FormLabel>
<FormDescription className={cn(index !== 0 && "sr-only")}>
Add links to your website, blog, or social media profiles.
</FormDescription>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={() => append({ value: "" })}
>
Add URL
</Button>
</div>
<Button type="submit">Update profile</Button>
</form>
</Form>
)
}

View file

@ -188,7 +188,7 @@ export default function StepperForm() {
<Form {...orgForm}>
<form
onSubmit={orgForm.handleSubmit(orgSubmit)}
className="space-y-8"
className="space-y-4"
>
<FormField
control={orgForm.control}

View file

@ -0,0 +1,291 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AlertCircle, CheckCircle2 } from "lucide-react";
import { createApiClient } from "@app/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { AxiosResponse } from "axios";
import {
RequestTotpSecretBody,
RequestTotpSecretResponse,
VerifyTotpBody,
VerifyTotpResponse
} from "@server/routers/auth";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { useToast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/utils";
import CopyTextBox from "@app/components/CopyTextBox";
import { QRCodeSVG } from "qrcode.react";
import { userUserContext } from "@app/hooks/useUserContext";
const enableSchema = z.object({
password: z.string().min(1, { message: "Password is required" })
});
const confirmSchema = z.object({
code: z.string().length(6, { message: "Invalid code" })
});
type Enable2FaProps = {
open: boolean;
setOpen: (val: boolean) => void;
};
export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
const [step, setStep] = useState(1);
const [secretKey, setSecretKey] = useState("");
const [verificationCode, setVerificationCode] = useState("");
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const { toast } = useToast();
const { user, updateUser } = userUserContext();
const api = createApiClient(useEnvContext());
const enableForm = useForm<z.infer<typeof enableSchema>>({
resolver: zodResolver(enableSchema),
defaultValues: {
password: ""
}
});
const confirmForm = useForm<z.infer<typeof confirmSchema>>({
resolver: zodResolver(confirmSchema),
defaultValues: {
code: ""
}
});
const request2fa = async (values: z.infer<typeof enableSchema>) => {
setLoading(true);
const res = await api
.post<AxiosResponse<RequestTotpSecretResponse>>(
`/auth/2fa/request`,
{
password: values.password
} as RequestTotpSecretBody
)
.catch((e) => {
toast({
title: "Unable to enable 2FA",
description: formatAxiosError(
e,
"An error occurred while enabling 2FA"
),
variant: "destructive"
});
});
if (res && res.data.data.secret) {
setSecretKey(res.data.data.secret);
setStep(2);
}
setLoading(false);
};
const confirm2fa = async (values: z.infer<typeof confirmSchema>) => {
setLoading(true);
const res = await api
.post<AxiosResponse<VerifyTotpResponse>>(`/auth/2fa/enable`, {
code: values.code
} as VerifyTotpBody)
.catch((e) => {
toast({
title: "Unable to enable 2FA",
description: formatAxiosError(
e,
"An error occurred while enabling 2FA"
),
variant: "destructive"
});
});
if (res && res.data.data.valid) {
setBackupCodes(res.data.data.backupCodes || []);
updateUser({ twoFactorEnabled: true })
setStep(3);
}
setLoading(false);
};
const handleVerify = () => {
if (verificationCode.length !== 6) {
setError("Please enter a 6-digit code");
return;
}
if (verificationCode === "123456") {
setSuccess(true);
setStep(3);
} else {
setError("Invalid code. Please try again.");
}
};
return (
<Credenza
open={open}
onOpenChange={(val) => {
setOpen(val);
setLoading(false);
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
Enable Two-factor Authentication
</CredenzaTitle>
<CredenzaDescription>
Secure your account with an extra layer of protection
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{step === 1 && (
<Form {...enableForm}>
<form
onSubmit={enableForm.handleSubmit(request2fa)}
className="space-y-4"
id="form"
>
<div className="space-y-4">
<FormField
control={enableForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
)}
{step === 2 && (
<div className="space-y-4">
<p>
scan this qr code with your authenticator app or
enter the secret key manually:
</p>
<div classname="w-64 h-64 mx-auto flex items-center justify-center">
<qrcodesvg value={secretkey} size={256} />
</div>
<div className="max-w-md mx-auto">
<CopyTextBox
text={secretKey}
wrapText={false}
/>
</div>
<Form {...confirmForm}>
<form
onSubmit={confirmForm.handleSubmit(
confirm2fa
)}
className="space-y-4"
id="form"
>
<div className="space-y-4">
<FormField
control={confirmForm.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>
Verification Code
</FormLabel>
<FormControl>
<Input
type="code"
placeholder="Enter the 6-digit code from your authenticator app"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</form>
</Form>
</div>
)}
{step === 3 && (
<div className="space-y-4 text-center">
<CheckCircle2
className="mx-auto text-green-500"
size={48}
/>
<p className="font-semibold text-lg">
Two-Factor Authentication Enabled
</p>
<p>
Your account is now more secure. Don't forget to
save your backup codes.
</p>
<div className="max-w-md mx-auto">
<CopyTextBox text={backupCodes.join("\n")} />
</div>
</div>
)}
</CredenzaBody>
<CredenzaFooter>
{(step === 1 || step === 2) && (
<Button
type="submit"
form="form"
loading={loading}
disabled={loading}
>
Submit
</Button>
)}
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

284
src/components/Header.tsx Normal file
View file

@ -0,0 +1,284 @@
"use client";
import { createApiClient } from "@app/api";
import { Avatar, AvatarFallback } from "@app/components/ui/avatar";
import { Button } from "@app/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator
} from "@app/components/ui/command";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useToast } from "@app/hooks/useToast";
import { cn, formatAxiosError } from "@app/lib/utils";
import { ListOrgsResponse } from "@server/routers/org";
import {
Check,
ChevronsUpDown,
Laptop,
LogOut,
Moon,
Plus,
Sun
} from "lucide-react";
import { useTheme } from "next-themes";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import Enable2FaForm from "./Enable2FaForm";
import { userUserContext } from "@app/hooks/useUserContext";
type HeaderProps = {
orgId?: string;
orgs?: ListOrgsResponse["orgs"];
};
export function Header({ orgId, orgs }: HeaderProps) {
const { toast } = useToast();
const { setTheme, theme } = useTheme();
const { user, updateUser } = userUserContext();
const [open, setOpen] = useState(false);
const [userTheme, setUserTheme] = useState<"light" | "dark" | "system">(
theme as "light" | "dark" | "system"
);
const [openEnable2fa, setOpenEnable2fa] = useState(false);
const router = useRouter();
const api = createApiClient(useEnvContext());
function getInitials() {
return user.email.substring(0, 2).toUpperCase();
}
function logout() {
api.post("/auth/logout")
.catch((e) => {
console.error("Error logging out", e);
toast({
title: "Error logging out",
description: formatAxiosError(e, "Error logging out")
});
})
.then(() => {
router.push("/auth/login");
});
}
function handleThemeChange(theme: "light" | "dark" | "system") {
setUserTheme(theme);
setTheme(theme);
}
return (
<>
<Enable2FaForm open={openEnable2fa} setOpen={setOpenEnable2fa} />
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="relative h-10 w-10 rounded-full"
>
<Avatar className="h-9 w-9">
<AvatarFallback>
{getInitials()}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-56"
align="start"
forceMount
>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
Signed in as
</p>
<p className="text-xs leading-none text-muted-foreground">
{user.email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{!user.twoFactorEnabled && (
<DropdownMenuItem
onClick={() => setOpenEnable2fa(true)}
>
<span>Enable Two-factor</span>
</DropdownMenuItem>
)}
{user.twoFactorEnabled && (
<DropdownMenuItem>
<span>Disable Two-factor</span>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuLabel>Theme</DropdownMenuLabel>
{(["light", "dark", "system"] as const).map(
(themeOption) => (
<DropdownMenuItem
key={themeOption}
onClick={() =>
handleThemeChange(themeOption)
}
>
{themeOption === "light" && (
<Sun className="mr-2 h-4 w-4" />
)}
{themeOption === "dark" && (
<Moon className="mr-2 h-4 w-4" />
)}
{themeOption === "system" && (
<Laptop className="mr-2 h-4 w-4" />
)}
<span className="capitalize">
{themeOption}
</span>
{userTheme === themeOption && (
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<span className="h-2 w-2 rounded-full bg-primary"></span>
</span>
)}
</DropdownMenuItem>
)
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout()}>
<LogOut className="mr-2 h-4 w-4" />
<span>Log out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<span className="truncate max-w-[150px] md:max-w-none font-medium">
{user.email}
</span>
</div>
<div className="flex items-center">
<div className="hidden md:block">
<div className="flex items-center gap-4 mr-4">
<Link
href="/docs"
className="text-muted-foreground hover:text-foreground"
>
Documentation
</Link>
<Link
href="/support"
className="text-muted-foreground hover:text-foreground"
>
Support
</Link>
</div>
</div>
{orgs && (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="lg"
role="combobox"
aria-expanded={open}
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">
<span className="font-bold text-sm">
Organization
</span>
<span className="text-sm text-muted-foreground">
{orgId
? orgs?.find(
(org) =>
org.orgId ===
orgId
)?.name
: "None selected"}
</span>
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="[100px] md:w-[180px] p-0">
<Command>
<CommandInput placeholder="Search..." />
<CommandEmpty>
No organizations found.
</CommandEmpty>
<CommandGroup heading="Create">
<CommandList>
<CommandItem
onSelect={(currentValue) => {
router.push("/setup");
}}
>
<Plus className="mr-2 h-4 w-4" />
New Organization
</CommandItem>
</CommandList>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Organizations">
<CommandList>
{orgs.map((org) => (
<CommandItem
key={org.orgId}
onSelect={(
currentValue
) => {
router.push(
`/${org.orgId}/settings`
);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
orgId === org.orgId
? "opacity-100"
: "opacity-0"
)}
/>
{org.name}
</CommandItem>
))}
</CommandList>
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
)}
</div>
</div>
</>
);
}
export default Header;

View file

@ -12,14 +12,14 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@/components/ui/form";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
CardTitle
} from "@/components/ui/card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { LoginResponse } from "@server/routers/auth";
@ -29,6 +29,14 @@ import { formatAxiosError } from "@app/lib/utils";
import { LockIcon } from "lucide-react";
import { createApiClient } from "@app/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot
} from "./ui/input-otp";
import Link from "next/link";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
type LoginFormProps = {
redirect?: string;
@ -39,7 +47,11 @@ const formSchema = z.object({
email: z.string().email({ message: "Invalid email address" }),
password: z
.string()
.min(8, { message: "Password must be at least 8 characters" }),
.min(8, { message: "Password must be at least 8 characters" })
});
const mfaSchema = z.object({
code: z.string().length(6, { message: "Invalid code" })
});
export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
@ -50,17 +62,26 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [mfaRequested, setMfaRequested] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
},
password: ""
}
});
async function onSubmit(values: z.infer<typeof formSchema>) {
const { email, password } = values;
const mfaForm = useForm<z.infer<typeof mfaSchema>>({
resolver: zodResolver(mfaSchema),
defaultValues: {
code: ""
}
});
async function onSubmit(values: any) {
const { email, password } = form.getValues();
const { code } = mfaForm.getValues();
setLoading(true);
@ -68,18 +89,30 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
.post<AxiosResponse<LoginResponse>>("/auth/login", {
email,
password,
code
})
.catch((e) => {
console.error(e);
setError(
formatAxiosError(e, "An error occurred while logging in"),
formatAxiosError(e, "An error occurred while logging in")
);
});
if (res && res.status === 200) {
if (res) {
setError(null);
if (res.data?.data?.emailVerificationRequired) {
const data = res.data.data;
console.log(data);
if (data?.codeRequested) {
setMfaRequested(true);
setLoading(false);
mfaForm.reset();
return;
}
if (data?.emailVerificationRequired) {
if (redirect) {
router.push(`/auth/verify-email?redirect=${redirect}`);
} else {
@ -97,8 +130,13 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
}
return (
<div className="space-y-8">
{!mfaRequested && (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="email"
@ -115,6 +153,8 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
</FormItem>
)}
/>
<div className="space-y-4">
<FormField
control={form.control}
name="password"
@ -132,16 +172,103 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
</FormItem>
)}
/>
<div className="text-center">
<Link
href={`/auth/reset-password${form.getValues().email ? `?email=${form.getValues().email}` : ""}`}
className="text-sm text-muted-foreground"
>
Forgot password?
</Link>
</div>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Button type="submit" className="w-full" loading={loading}>
<Button
type="submit"
className="w-full"
loading={loading}
>
<LockIcon className="w-4 h-4 mr-2" />
Login
</Button>
</form>
</Form>
)}
{mfaRequested && (
<Form {...mfaForm}>
<form
onSubmit={mfaForm.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={mfaForm.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>Authenticator Code</FormLabel>
<FormControl>
<div className="flex justify-center">
<InputOTP
maxLength={6}
{...field}
pattern={
REGEXP_ONLY_DIGITS_AND_CHARS
}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-4">
<Button
type="submit"
className="w-full"
loading={loading}
>
<LockIcon className="w-4 h-4 mr-2" />
Submit Code
</Button>
<Button
type="button"
className="w-full"
variant="outline"
onClick={() => {
setMfaRequested(false);
mfaForm.reset();
}}
>
Back to Login
</Button>
</div>
</form>
</Form>
)}
</div>
);
}

View file

@ -12,7 +12,7 @@ interface TopbarNavProps extends React.HTMLAttributes<HTMLElement> {
icon: React.ReactNode;
}[];
disabled?: boolean;
orgId: string;
orgId?: string;
}
export function TopbarNav({
@ -36,10 +36,10 @@ export function TopbarNav({
{items.map((item) => (
<Link
key={item.href}
href={item.href.replace("{orgId}", orgId)}
href={item.href.replace("{orgId}", orgId || "")}
className={cn(
"relative px-3 py-3 text-md",
pathname.startsWith(item.href.replace("{orgId}", orgId))
pathname.startsWith(item.href.replace("{orgId}", orgId || ""))
? "border-b-2 border-primary text-primary font-medium"
: "hover:text-primary text-muted-foreground font-medium",
"whitespace-nowrap",

View file

@ -1,176 +0,0 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { CalendarIcon, CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { cn } from "@/lib/utils"
import { toast } from "@/hooks/useToast"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
const languages = [
{ label: "English", value: "en" },
{ label: "French", value: "fr" },
{ label: "German", value: "de" },
{ label: "Spanish", value: "es" },
{ label: "Portuguese", value: "pt" },
{ label: "Russian", value: "ru" },
{ label: "Japanese", value: "ja" },
{ label: "Korean", value: "ko" },
{ label: "Chinese", value: "zh" },
] as const
const accountFormSchema = z.object({
name: z
.string()
.min(2, {
message: "Name must be at least 2 characters.",
})
.max(30, {
message: "Name must not be longer than 30 characters.",
}),
dob: z.date({
required_error: "A date of birth is required.",
}),
language: z.string({
required_error: "Please select a language.",
}),
})
type AccountFormValues = z.infer<typeof accountFormSchema>
// This can come from your database or API.
const defaultValues: Partial<AccountFormValues> = {
// name: "Your name",
// dob: new Date("2023-01-23"),
}
export function AccountForm() {
const form = useForm<AccountFormValues>({
resolver: zodResolver(accountFormSchema),
defaultValues,
})
function onSubmit(data: AccountFormValues) {
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
),
})
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Your name" {...field} />
</FormControl>
<FormDescription>
This is the name that will be displayed on your profile and in
emails.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Language</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-[200px] justify-between",
!field.value && "text-muted-foreground"
)}
>
{field.value
? languages.find(
(language) => language.value === field.value
)?.label
: "Select language"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search language..." />
<CommandList>
<CommandEmpty>No language found.</CommandEmpty>
<CommandGroup>
{languages.map((language) => (
<CommandItem
value={language.label}
key={language.value}
onSelect={() => {
form.setValue("language", language.value)
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
language.value === field.value
? "opacity-100"
: "opacity-0"
)}
/>
{language.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
This is the language that will be used in the dashboard.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Update account</Button>
</form>
</Form>
)
}

View file

@ -1,179 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronDownIcon } from "@radix-ui/react-icons";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { cn } from "@/lib/utils";
import { toast } from "@/hooks/useToast";
import { Button, buttonVariants } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useSiteContext } from "@app/hooks/useSiteContext";
const appearanceFormSchema = z.object({
theme: z.enum(["light", "dark"], {
required_error: "Please select a theme.",
}),
font: z.enum(["inter", "manrope", "system"], {
invalid_type_error: "Select a font",
required_error: "Please select a font.",
}),
});
type AppearanceFormValues = z.infer<typeof appearanceFormSchema>;
// This can come from your database or API.
const defaultValues: Partial<AppearanceFormValues> = {
theme: "light",
};
export function AppearanceForm() {
const site = useSiteContext();
console.log(site);
const form = useForm<AppearanceFormValues>({
resolver: zodResolver(appearanceFormSchema),
defaultValues,
});
function onSubmit(data: AppearanceFormValues) {
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">
{JSON.stringify(data, null, 2)}
</code>
</pre>
),
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="font"
render={({ field }) => (
<FormItem>
<FormLabel>Font</FormLabel>
<div className="relative w-max">
<FormControl>
<select
className={cn(
buttonVariants({
variant: "outline",
}),
"w-[200px] appearance-none font-normal"
)}
{...field}
>
<option value="inter">Inter</option>
<option value="manrope">Manrope</option>
<option value="system">System</option>
</select>
</FormControl>
<ChevronDownIcon className="absolute right-3 top-2.5 h-4 w-4 opacity-50" />
</div>
<FormDescription>
Set the font you want to use in the dashboard.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="theme"
render={({ field }) => (
<FormItem className="space-y-1">
<FormLabel>Theme</FormLabel>
<FormDescription>
Select the theme for the dashboard.
</FormDescription>
<FormMessage />
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="grid max-w-md grid-cols-2 gap-8 pt-2"
>
<FormItem>
<FormLabel className="[&:has([data-state=checked])>div]:border-primary">
<FormControl>
<RadioGroupItem
value="light"
className="sr-only"
/>
</FormControl>
<div className="items-center rounded-md border-2 border-muted p-1 hover:border-accent">
<div className="space-y-2 rounded-sm bg-[#ecedef] p-2">
<div className="space-y-2 rounded-md bg-white p-2 shadow-sm">
<div className="h-2 w-[80px] rounded-lg bg-[#ecedef]" />
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-[#ecedef]" />
<div className="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
</div>
</div>
</div>
<span className="block w-full p-2 text-center font-normal">
Light
</span>
</FormLabel>
</FormItem>
<FormItem>
<FormLabel className="[&:has([data-state=checked])>div]:border-primary">
<FormControl>
<RadioGroupItem
value="dark"
className="sr-only"
/>
</FormControl>
<div className="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
<div className="space-y-2 rounded-sm bg-slate-950 p-2">
<div className="space-y-2 rounded-md bg-slate-800 p-2 shadow-sm">
<div className="h-2 w-[80px] rounded-lg bg-slate-400" />
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-slate-400" />
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
</div>
<div className="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
<div className="h-4 w-4 rounded-full bg-slate-400" />
<div className="h-2 w-[100px] rounded-lg bg-slate-400" />
</div>
</div>
</div>
<span className="block w-full p-2 text-center font-normal">
Dark
</span>
</FormLabel>
</FormItem>
</RadioGroup>
</FormItem>
)}
/>
<Button type="submit">Update preferences</Button>
</form>
</Form>
);
}

View file

@ -1,132 +0,0 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { toast } from "@/hooks/useToast"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
const items = [
{
id: "recents",
label: "Recents",
},
{
id: "home",
label: "Home",
},
{
id: "applications",
label: "Applications",
},
{
id: "desktop",
label: "Desktop",
},
{
id: "downloads",
label: "Downloads",
},
{
id: "documents",
label: "Documents",
},
] as const
const displayFormSchema = z.object({
items: z.array(z.string()).refine((value) => value.some((item) => item), {
message: "You have to select at least one item.",
}),
})
type DisplayFormValues = z.infer<typeof displayFormSchema>
// This can come from your database or API.
const defaultValues: Partial<DisplayFormValues> = {
items: ["recents", "home"],
}
export function DisplayForm() {
const form = useForm<DisplayFormValues>({
resolver: zodResolver(displayFormSchema),
defaultValues,
})
function onSubmit(data: DisplayFormValues) {
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
),
})
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="items"
render={() => (
<FormItem>
<div className="mb-4">
<FormLabel className="text-base">Sidebar</FormLabel>
<FormDescription>
Select the items you want to display in the sidebar.
</FormDescription>
</div>
{items.map((item) => (
<FormField
key={item.id}
control={form.control}
name="items"
render={({ field }) => {
return (
<FormItem
key={item.id}
className="flex flex-row items-start space-x-3 space-y-0"
>
<FormControl>
<Checkbox
checked={field.value?.includes(item.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, item.id])
: field.onChange(
field.value?.filter(
(value) => value !== item.id
)
)
}}
/>
</FormControl>
<FormLabel className="font-normal">
{item.label}
</FormLabel>
</FormItem>
)
}}
/>
))}
<FormMessage />
</FormItem>
)}
/>
<Button type="submit">Update display</Button>
</form>
</Form>
)
}

View file

@ -1,222 +0,0 @@
"use client"
import Link from "next/link"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { toast } from "@/hooks/useToast"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Switch } from "@/components/ui/switch"
const notificationsFormSchema = z.object({
type: z.enum(["all", "mentions", "none"], {
required_error: "You need to select a notification type.",
}),
mobile: z.boolean().default(false).optional(),
communication_emails: z.boolean().default(false).optional(),
social_emails: z.boolean().default(false).optional(),
marketing_emails: z.boolean().default(false).optional(),
security_emails: z.boolean(),
})
type NotificationsFormValues = z.infer<typeof notificationsFormSchema>
// This can come from your database or API.
const defaultValues: Partial<NotificationsFormValues> = {
communication_emails: false,
marketing_emails: false,
social_emails: true,
security_emails: true,
}
export function NotificationsForm() {
const form = useForm<NotificationsFormValues>({
resolver: zodResolver(notificationsFormSchema),
defaultValues,
})
function onSubmit(data: NotificationsFormValues) {
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
),
})
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem className="space-y-3">
<FormLabel>Notify me about...</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex flex-col space-y-1"
>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="all" />
</FormControl>
<FormLabel className="font-normal">
All new messages
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="mentions" />
</FormControl>
<FormLabel className="font-normal">
Direct messages and mentions
</FormLabel>
</FormItem>
<FormItem className="flex items-center space-x-3 space-y-0">
<FormControl>
<RadioGroupItem value="none" />
</FormControl>
<FormLabel className="font-normal">Nothing</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<h3 className="mb-4 text-lg font-medium">Email Notifications</h3>
<div className="space-y-4">
<FormField
control={form.control}
name="communication_emails"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Communication emails
</FormLabel>
<FormDescription>
Receive emails about your account activity.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="marketing_emails"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Marketing emails
</FormLabel>
<FormDescription>
Receive emails about new products, features, and more.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="social_emails"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Social emails</FormLabel>
<FormDescription>
Receive emails for friend requests, follows, and more.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="security_emails"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Security emails</FormLabel>
<FormDescription>
Receive emails about your account activity and security.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled
aria-readonly
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
<FormField
control={form.control}
name="mobile"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
Use different settings for my mobile devices
</FormLabel>
<FormDescription>
You can manage your mobile notifications in the{" "}
<Link href="/examples/forms">mobile settings</Link> page.
</FormDescription>
</div>
</FormItem>
)}
/>
<Button type="submit">Update notifications</Button>
</form>
</Form>
)
}

View file

@ -1,192 +0,0 @@
"use client"
import Link from "next/link"
import { zodResolver } from "@hookform/resolvers/zod"
import { useFieldArray, useForm } from "react-hook-form"
import { z } from "zod"
import { cn } from "@/lib/utils"
import { toast } from "@/hooks/useToast"
import { Button } from "@/components/ui/button"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
const profileFormSchema = z.object({
username: z
.string()
.min(2, {
message: "Username must be at least 2 characters.",
})
.max(30, {
message: "Username must not be longer than 30 characters.",
}),
email: z
.string({
required_error: "Please select an email to display.",
})
.email(),
bio: z.string().max(160).min(4),
urls: z
.array(
z.object({
value: z.string().url({ message: "Please enter a valid URL." }),
})
)
.optional(),
})
type ProfileFormValues = z.infer<typeof profileFormSchema>
// This can come from your database or API.
const defaultValues: Partial<ProfileFormValues> = {
bio: "I own a computer.",
urls: [
{ value: "https://shadcn.com" },
{ value: "http://twitter.com/shadcn" },
],
}
export function ProfileForm() {
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileFormSchema),
defaultValues,
mode: "onChange",
})
const { fields, append } = useFieldArray({
name: "urls",
control: form.control,
})
function onSubmit(data: ProfileFormValues) {
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
),
})
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="shadcn" {...field} />
</FormControl>
<FormDescription>
This is your public display name. It can be your real name or a
pseudonym. You can only change this once every 30 days.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a verified email to display" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="m@example.com">m@example.com</SelectItem>
<SelectItem value="m@google.com">m@google.com</SelectItem>
<SelectItem value="m@support.com">m@support.com</SelectItem>
</SelectContent>
</Select>
<FormDescription>
You can manage verified email addresses in your{" "}
<Link href="/examples/forms">email settings</Link>.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bio"
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea
placeholder="Tell us a little bit about yourself"
className="resize-none"
{...field}
/>
</FormControl>
<FormDescription>
You can <span>@mention</span> other users and organizations to
link to them.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
{fields.map((field, index) => (
<FormField
control={form.control}
key={field.id}
name={`urls.${index}.value`}
render={({ field }) => (
<FormItem>
<FormLabel className={cn(index !== 0 && "sr-only")}>
URLs
</FormLabel>
<FormDescription className={cn(index !== 0 && "sr-only")}>
Add links to your website, blog, or social media profiles.
</FormDescription>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={() => append({ value: "" })}
>
Add URL
</Button>
</div>
<Button type="submit">Update profile</Button>
</form>
</Form>
)
}

View file

@ -1,14 +1,27 @@
import * as React from "react"
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
import { EyeOff, Eye } from "lucide-react";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
const [showPassword, setShowPassword] = React.useState(false);
const togglePasswordVisibility = () => setShowPassword(!showPassword);
console.log("type", type);
return (
<div className="relative">
<input
type={type}
type={
type === "password"
? showPassword
? "text"
: "password"
: type
}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
@ -16,9 +29,25 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
ref={ref}
{...props}
/>
)
{type === "password" && (
<div className="absolute inset-y-0 right-0 flex cursor-pointer items-center pr-3 text-gray-400">
{showPassword ? (
<EyeOff
className="h-4 w-4"
onClick={togglePasswordVisibility}
/>
) : (
<Eye
className="h-4 w-4"
onClick={togglePasswordVisibility}
/>
)}
</div>
)}
</div>
);
}
)
Input.displayName = "Input"
);
Input.displayName = "Input";
export { Input }
export { Input };

View file

@ -1,4 +1,11 @@
import { GetUserResponse } from "@server/routers/user";
import { createContext } from "react";
export const UserContext = createContext<GetUserResponse | null>(null);
interface UserContextType {
user: GetUserResponse;
updateUser: (updatedUser: Partial<GetUserResponse>) => void;
}
const UserContext = createContext<UserContextType | undefined>(undefined);
export default UserContext;

View file

@ -1,7 +1,10 @@
import { UserContext } from "@app/contexts/userContext";
import UserContext from "@app/contexts/userContext";
import { useContext } from "react";
export function useUserContext() {
const user = useContext(UserContext);
return user;
export function userUserContext() {
const context = useContext(UserContext);
if (context === undefined) {
throw new Error("useUserContext must be used within a UserProvider");
}
return context;
}

View file

@ -1,16 +1,37 @@
"use client";
import { UserContext } from "@app/contexts/userContext";
import UserContext from "@app/contexts/userContext";
import { GetUserResponse } from "@server/routers/user";
import { ReactNode } from "react";
import { useState } from "react";
type UserProviderProps = {
interface UserProviderProps {
children: React.ReactNode;
user: GetUserResponse;
children: ReactNode;
};
}
export function UserProvider({ user, children }: UserProviderProps) {
return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
export function UserProvider({ children, user: u }: UserProviderProps) {
const [user, setUser] = useState<GetUserResponse>(u);
const updateUser = (updatedUser: Partial<GetUserResponse>) => {
if (!user) {
throw new Error("No user to update");
}
setUser((prev) => {
if (!prev) {
return prev;
}
return {
...prev,
...updatedUser
};
});
};
return (
<UserContext.Provider value={{ user: user, updateUser: updateUser }}>
{children}
</UserContext.Provider>
);
}
export default UserProvider;