Merge pull request #1 from dqnid/feature/mono-branch

Feature/mono branch
This commit is contained in:
Daniel Heras Quesada authored and GitHub committed 2024-12-28 22:06:35 +01:00
commit 1a6f13e862
122 files changed
+9503 -5557

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
{
"rules": {
"type-enum": [
2,
"always",
[
"feat",
"fix",
"docs",
"style",
"refactor",
"perf",
"test",
"build",
"ci",
"chore",
"revert"
]
],
"scope-case": [2, "always", "lower-case"],
"subject-case": [2, "never", ["sentence-case", "start-case", "pascal-case", "upper-case"]],
"subject-empty": [2, "never"],
"subject-full-stop": [2, "never", "."],
"header-max-length": [2, "always", 72]
}
}
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
echo -e "\e[35m➤ [COMMIT]\e[0m Checking message\e[37m - \e[33m$(date +"%H:%M:%S")\e[0m"
npx commitlint --edit $1
if [[ $? != 0 ]]; then
echo -e "\e[31m➤ [COMMIT]\e[0m Wrong format on message\e[37m - \e[33m$(date +"%H:%M:%S")\e[0m"
exit -1
fi
echo -e "\e[32m➤ [COMMIT]\e[0m Message ok\e[37m - \e[33m$(date +"%H:%M:%S")\e[0m"
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
cd ./front
echo -e "\e[35m➤ [PRETTIER]\e[0m Formatting front files\e[37m - \e[33m$(date +"%H:%M:%S")\e[0m"
npx prettier . --write --log-level silent
echo -e "\e[35m➤ [ESLINT]\e[0m Checking front code\e[37m - \e[33m$(date +"%H:%M:%S")\e[0m"
npx lint-staged --silent
echo $?
if [[ $? != 0 ]]; then
echo -e "\e[31m➤ [ESLINT]\e[0m Errors in code\e[37m - \e[33m$(date +"%H:%M:%S")\e[0m"
exit -1;
fi
echo -e "\e[32m➤ [ESLINT]\e[0m Code ok\e[37m - \e[33m$(date +"%H:%M:%S")\e[0m"
cd ..
@@ -0,0 +1,21 @@
import { Router } from "express";
import { ResponseSuccess } from "@/utils/response/response-success.model";
import {{pascalCase name}}Service from "./{{kebabCase name}}.service";
export const {{camelCase name}}Routes = Router();
const {{camelCase name}}Service = new {{pascalCase name}}Service();
{{camelCase name}}Routes.get("/", async (_, res) => {
try {
const response = await {{camelCase name}}Service.getAll{{pascalCase name}}();
res.status(200);
res.send(new ResponseSuccess(response));
} catch (e) {
res.status(500);
res.send(e);
}
});
export { {{camelCase name}}Service };
@@ -0,0 +1,9 @@
class {{pascalCase name}}Service {
constructor(private info?: string) {}
async getAll{{pascalCase name}}() {
return Promise.resolve(this.info);
}
}
export default {{pascalCase name}}Service;
+144
View File
@@ -0,0 +1,144 @@
# Created by https://www.toptal.com/developers/gitignore/api/node
# Edit at https://www.toptal.com/developers/gitignore?templates=node
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
# End of https://www.toptal.com/developers/gitignore/api/node
+15
View File
@@ -0,0 +1,15 @@
FROM node:21-slim
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "run", "serve"]
+2388
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "express-backend",
"version": "1.0.0",
"scripts": {
"dev": "nodemon src/app.ts",
"start": "ts-node src/app.ts",
"build": "tsc",
"serve": "node dist/app.js"
},
"author": "Daniel Heras Quesada",
"license": "ISC",
"description": "",
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.7.4",
"express": "^4.21.0",
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"typescript": "^5.6.2"
},
"dependencies": {
"@types/bcrypt": "^5.0.2",
"@types/jsonwebtoken": "^9.0.7",
"bcrypt": "^5.1.1",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.11.3"
}
}
+33
View File
@@ -0,0 +1,33 @@
import express from "express";
import config from "./config";
import cors from "cors";
import { routes } from "./modules";
import { authorizationMiddleware } from "./modules/auth/auth.middleware";
const app = express();
const port = config.port;
if (config.enableCors) {
app.use(cors());
}
app.use(express.json());
// Global middleware
app.use(authorizationMiddleware as any); // TODO: move out of here
app.use((req, _res, next) => {
console.log(`LOG: new ${req.method} request for ${req.url}`);
next();
});
// Route specific middleware
app.use("/example", (req, _res, next) => {
console.log(`LOG: new ${req.method} example request for ${req.url}`);
next();
});
app.use(config.baseRoute, routes); // / serves as the base for the imported routes
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
+11
View File
@@ -0,0 +1,11 @@
import { configDotenv } from "dotenv";
configDotenv();
const config = {
enableCors: true,
port: process.env.PORT || 3000,
baseRoute: "/api/v1",
};
export default config;
+26
View File
@@ -0,0 +1,26 @@
import mysql, { QueryError } from "mysql2/promise";
import { ResponseError } from "./utils/response/response-error.model";
const db_pool = mysql.createPool({
host: process.env.DB_HOST ?? "localhost",
port: parseInt(process.env.DB_PORT ?? "3307"),
user: process.env.DB_USERNAME ?? "dbuser",
password: process.env.DB_PASSWORD ?? "securepassword",
database: process.env.DB_MAIN ?? "path",
});
async function DB_Query<T>(query: string): Promise<Partial<T>[]> {
try {
const [results, _fields] = await db_pool.query(query);
return results as T[];
} catch (e) {
const queryError = e as QueryError;
throw new ResponseError({
code: queryError.code,
number: queryError.errno,
detail: queryError.message,
});
}
}
export { db_pool as db_connection, DB_Query as db_query };
@@ -0,0 +1 @@
export const AUTH_KEY_SIZE = 30;
@@ -0,0 +1,85 @@
import { NextFunction, Request, Response } from "express";
import { authService } from "./auth.routes";
import { Role } from "../users/users.types";
import { ResponseError } from "../../utils/response/response-error.model";
import config from "../../config";
async function authorization(req: Request, res: Response, next: NextFunction) {
const resource = req.url.replace(config.baseRoute, "");
const isPublic = authorizationTable.some((permission) => {
const regex = new RegExp(permission.resource);
return (
regex.test(resource) &&
permission.actions.includes(req.method as HttpMethod) &&
permission.roles.includes(Role.Public)
);
});
if (isPublic) {
return next();
}
const authHeader = req.get("authorization");
const token = authHeader?.split(" ")[1];
if (token == null) {
res.status(401);
return res.send(new ResponseError(401));
}
const user = authService.verifyToken(token);
if (user && user instanceof Object) {
const authorized = authorizationTable.some((permission) => {
const regex = new RegExp(permission.resource);
return (
regex.test(resource) &&
permission.roles.some((role) => user.roles.includes(role)) &&
permission.actions.includes(req.method as HttpMethod)
);
});
if (authorized) {
return next();
}
}
res.status(401);
return res.send(new ResponseError(401));
}
type HttpMethod =
| "GET"
| "POST"
| "PUT"
| "DELETE"
| "PATCH"
| "OPTIONS"
| "HEAD";
type Permissions = {
roles: Array<Role>;
resource: string;
actions: Array<HttpMethod>;
};
const authorizationTable: Permissions[] = [
{
roles: [Role.Public],
resource: "^/auth.*", // begins with /auth (is auth would be ^\/auth$)
actions: ["POST"],
},
{
roles: [Role.Admin],
resource: "^/users.*",
actions: ["GET", "PUT", "POST", "DELETE"],
},
{
roles: [Role.User],
resource: "^/users.*",
actions: ["GET"],
},
{
roles: [Role.Public],
resource: "^/example.*",
actions: ["GET", "OPTIONS"],
},
];
export { authorization as authorizationMiddleware };
@@ -0,0 +1,33 @@
import { Router } from "express";
import { userService } from "../users/users.routes";
import { AuthService } from "./auth.service";
import { ResponseError } from "../../utils/response/response-error.model";
export const authRoutes = Router();
const authService = new AuthService(userService);
authRoutes.post("/login", async (req, res) => {
try {
if (!req.body) {
res.status(400);
res.send(new ResponseError(400));
}
const token = await authService.signIn(
req.body.username,
req.body.password,
);
if (token) {
res.status(200);
res.send({ access_token: token });
} else {
res.status(401);
res.send(new ResponseError(401));
}
} catch (e) {
res.status(500);
res.send(e);
}
});
export { authService };
@@ -0,0 +1,51 @@
import { sign, verify } from "jsonwebtoken";
import UsersService from "../users/users.service";
import { compare } from "bcrypt";
import { generateRandomString } from "./auth.utils";
import { AUTH_KEY_SIZE } from "./auth.constants";
import { Role } from "../users/users.types";
export class AuthService {
private secret_key: string;
constructor(private usersService: UsersService) {
this.secret_key = generateRandomString(AUTH_KEY_SIZE);
}
async signIn(username: string, password: string) {
const user = await this.usersService.getUserByUsername(username);
if (!user) {
return null;
}
const isSamePasswd = await compare(`${password}`, `${user?.password}`);
if (!isSamePasswd) return null;
const payload = {
sub: user.id,
username: user.username,
roles: user.roles,
picture: user.picture,
};
const token = sign(payload, this.secret_key, { expiresIn: 60 * 60 });
return token;
}
verifyToken(jwt: string) {
const token = jwt.split(".")[1];
if (!token) return false;
try {
const payload = verify(jwt, this.secret_key);
if (payload instanceof Object)
return {
username: payload.username as string,
roles: payload.roles as Role[],
};
else return false;
} catch (e) {
return false;
}
}
}
Whitespace-only changes.
@@ -0,0 +1,6 @@
function generateRandomString(size: number) {
const value = Math.random() * Math.pow(10, size);
return btoa(value.toString());
}
export { generateRandomString };
@@ -0,0 +1,8 @@
import { Router } from "express";
export const defaultRoute = Router();
defaultRoute.get("/", (_, res) => {
res.send("Nothing to see here");
res.status(200);
});
@@ -0,0 +1,40 @@
import { Router } from "express";
import ExampleService from "./example.service";
import { ResponseSuccess } from "../../utils/response/response-success.model";
import { ResponseError } from "../../utils/response/response-error.model";
export const exampleRoutes = Router();
const exampleService = new ExampleService();
exampleRoutes.get("/", async (_, res) => {
try {
const response = await exampleService.getAllExamples();
res.status(200);
res.send(new ResponseSuccess(response));
} catch (e) {
res.status(500);
res.send(e);
}
});
exampleRoutes.get("/:id", async (req, res) => {
try {
const response = await exampleService.getExampleById(
parseInt(req.params.id),
);
if (response) {
res.status(200);
res.send(new ResponseSuccess(response));
} else {
res.status(404);
res.send(new ResponseError({ code: "NOT_FOUND", number: 404 }));
}
} catch (e) {
res.status(500);
res.send(e);
}
});
export { exampleService };
@@ -0,0 +1,23 @@
import { db_query } from "../../db";
import { Example } from "./example.types";
class ExampleService {
constructor(private info?: string) {}
async getAllExamples(): Promise<Example[]> {
const examples = await db_query("select * from example");
return examples as Example[];
}
async getExampleById(id: number): Promise<Example | null> {
const example = await db_query(
`select * from example as example WHERE id = ${id};`,
);
if (example.length) {
return example[0] as Example;
}
return null;
}
}
export default ExampleService;
@@ -0,0 +1,7 @@
export type Example = {
id: number;
name: string;
description: string;
image: string;
created_at: string;
};
+16
View File
@@ -0,0 +1,16 @@
import express from "express";
import { defaultRoute } from "./default.route";
import { userRoutes } from "./users/users.routes";
import { authRoutes } from "./auth/auth.routes";
import { exampleRoutes } from "./example/example.routes";
export const routes = express.Router();
/*
* Routes
* */
routes.use("/", defaultRoute);
routes.use("/users", userRoutes);
routes.use("/auth", authRoutes);
routes.use("/example", exampleRoutes);
@@ -0,0 +1,39 @@
import { Router } from "express";
import UsersService from "./users.service";
import { sanitize_user } from "./users.utils";
import { ResponseSuccess } from "../../utils/response/response-success.model";
import { ResponseError } from "../../utils/response/response-error.model";
export const userRoutes = Router();
const userService = new UsersService();
//TODO: block access to NON-admins or simply comment
userRoutes.get("/", async (_, res) => {
try {
const response = await userService.getAllUsers();
res.status(200);
res.send(new ResponseSuccess(response.map((user) => sanitize_user(user))));
} catch (e) {
res.status(500);
res.send(e);
}
});
userRoutes.get("/:username", async (req, res) => {
try {
const response = await userService.getUserByUsername(req.params.username);
if (response) {
res.status(200);
res.send(new ResponseSuccess(sanitize_user(response)));
} else {
res.status(404);
res.send(new ResponseError({ code: "NOT_FOUND", number: 404 }));
}
} catch (e) {
res.status(500);
res.send(e);
}
});
export { userService };
@@ -0,0 +1,31 @@
import { db_query } from "../../db";
import { User } from "./users.types";
class UsersService {
constructor() {}
async getAllUsers(): Promise<User[]> {
const data = await db_query("select * from user");
const users: User[] = data.map((user: any) => ({
...user,
roles: user.roles.split(";"),
}));
return users;
}
async getUserByUsername(username: string): Promise<User | null> {
const data = await db_query(
`select * from user as user WHERE LOWER(username) = LOWER('${username}');`,
);
if (data.length) {
const user = {
...(data[0] as User),
roles: (data[0] as any).roles.split(";"),
};
return user;
}
return null;
}
}
export default UsersService;
@@ -0,0 +1,17 @@
export enum Role {
Public = "public",
User = "user",
Manager = "manager",
Admin = "admin",
}
export type User = {
id: number;
username: string;
password: string;
roles: Role[];
picture: string;
created_at: string;
};
export type UserDetailResult = Omit<User, "password">;
@@ -0,0 +1,7 @@
import { User, UserDetailResult } from "./users.types";
// TODO: prettify this
export function sanitize_user(user: User): UserDetailResult {
delete (user as any).password;
return user;
}
@@ -0,0 +1,33 @@
type BasicError = {
code: string | number;
number?: number;
detail?: string;
status?: number;
suggestion?: string;
};
const code_error_mapping: Record<number, BasicError> = {
400: {
code: "BAD REQUEST",
status: 400,
},
401: {
code: "UNAUTHORIZED",
status: 401,
},
};
export class ResponseError extends Error {
public error: BasicError | number;
constructor(
error: BasicError | number,
public timestamp: number = Date.now(),
) {
super();
if (error instanceof Object) {
this.error = error;
} else {
this.error = code_error_mapping[error];
}
}
}
@@ -0,0 +1,16 @@
export class ResponseSuccess {
public data?: unknown;
public results?: unknown[];
public count?: number;
constructor(data: unknown) {
if (data instanceof Array) {
this.results = data;
this.count = data.length;
} else if (data instanceof Object) {
Object.assign(this, data);
} else {
this.data = data;
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+9 -1
View File
@@ -1,6 +1,14 @@
version: "1.2"
services:
# front:
# build:
# context: ./front
# dockerfile: Dockerfile
# container_name: nextjs-app
# ports:
# - "3016:3016"
#
mysql:
image: mysql
restart: always
@@ -19,7 +27,7 @@ services:
backend:
build:
context: ./back
context: ./back-express
dockerfile: Dockerfile
container_name: nestjs-app
ports:
-9
View File
@@ -1,9 +0,0 @@
import styles from "./.module.scss";
type Props = {}
export const :React.FC<Props> = ({}) => {
return (
<div data-testid="{}" className={styles.container}></div>
)
}
+4
View File
@@ -4,3 +4,7 @@ NEXT_PUBLIC_API_URL=http://localhost:3016
# GEN AUTH_SECRET: $ openssl rand -base64 32
NEXTAUTH_SECRET=6lHRWUvCBtqlgTWc6aFn6s6PudYjuN6oUY+RrcEntTU=
NEXTAUTH_URL=http://localhost:3016
NEXT_PUBLIC_AUTH_URL=http://localhost:3000/api/v1
NEXT_PUBLIC_BACKEND_URL=http://localhost:3000/api/v1
+4
View File
@@ -0,0 +1,4 @@
*.[tj]s
*.[tj]sx
!src/**/*
+1 -1
View File
@@ -1,3 +1,3 @@
{
"extends": "next/core-web-vitals"
"extends": ["next/core-web-vitals", "prettier"]
}
-2
View File
@@ -1,2 +0,0 @@
.container {
}
+6
View File
@@ -0,0 +1,6 @@
# Ignore artifacts:
.next
.blueprints
node_modules
build
coverage
+7
View File
@@ -0,0 +1,7 @@
{
"printWidth": 85,
"tabWidth": 4,
"singleQuote": false,
"semi": true,
"arrowParens": "always"
}
+16
View File
@@ -0,0 +1,16 @@
FROM node:21-slim
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3016
# Start the NestJS application
CMD ["npm", "run", "start"]
+2 -2
View File
@@ -24,8 +24,8 @@ This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-opti
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
+17 -17
View File
@@ -2,23 +2,23 @@ import path from "path";
/** @type {import('next').NextConfig} */
const nextConfig = {
/**
* https://nextjs.org/docs/app/building-your-application/styling/sass
*/
sassOptions: {
// optional: prependData: `@import "@/styles/variables";`,
includePaths: [path.join("@", "styles")],
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "picsum.photos",
port: "",
pathname: "/**",
},
],
},
/**
* https://nextjs.org/docs/app/building-your-application/styling/sass
*/
sassOptions: {
// optional: prependData: `@import "@/styles/variables";`,
includePaths: [path.join("@", "styles")],
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "picsum.photos",
port: "",
pathname: "/**",
},
],
},
};
export default nextConfig;
+5319 -4582
View File
File diff suppressed because it is too large. Load diff
+28 -26
View File
@@ -1,28 +1,30 @@
{
"name": "front-next-archetype",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3016",
"build": "next build",
"start": "next start -p 3016",
"lint": "next lint"
},
"dependencies": {
"next": "14.2.3",
"next-auth": "^4.24.7",
"postcss": "^8.4.39",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.3",
"prettier": "3.3.3",
"sass": "^1.77.8",
"typescript": "^5"
}
"name": "front-next-archetype",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3016",
"build": "next build",
"start": "next start -p 3016",
"lint": "next lint"
},
"dependencies": {
"next": "14.2.3",
"next-auth": "^4.24.7",
"postcss": "^8.4.39",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.3",
"eslint-config-prettier": "^9.1.0",
"lint-staged": "^15.2.10",
"prettier": "3.3.3",
"sass": "^1.77.8",
"typescript": "^5"
}
}
+13 -13
View File
@@ -1,15 +1,15 @@
export default function Home() {
return (
<main
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
}}
>
Main page
</main>
);
return (
<main
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
}}
>
Main page
</main>
);
}
+29 -29
View File
@@ -2,38 +2,38 @@ import { withAuth } from "next-auth/middleware";
import { authOptions } from "./modules/auth/configs/auth.options";
export default withAuth({
pages: authOptions.pages,
callbacks: {
authorized({ req, token }) {
if (token && token.apiSession.exp * 1000 > Date.now()) {
return true;
}
const pathname = req.nextUrl.pathname;
return (
pathname.startsWith("/_next/") ||
pathname.startsWith("/favicon.ico") ||
pathname.startsWith("/assets/")
);
pages: authOptions.pages,
callbacks: {
authorized({ req, token }) {
if (token && token.apiSession.exp * 1000 > Date.now()) {
return true;
}
const pathname = req.nextUrl.pathname;
return (
pathname.startsWith("/_next/") ||
pathname.startsWith("/favicon.ico") ||
pathname.startsWith("/assets/")
);
},
},
},
});
const value = {
token: {
name: "dqnid",
picture: "https://picsum.photos/200/300",
sub: "dqnid",
user: {
id: "dqnid",
roles: ["user", "manager", "admin"],
image: "https://picsum.photos/200/300",
name: "dqnid",
token: {
name: "dqnid",
picture: "https://picsum.photos/200/300",
sub: "dqnid",
user: {
id: "dqnid",
roles: ["user", "manager", "admin"],
image: "https://picsum.photos/200/300",
name: "dqnid",
},
apiSession: {
exp: 1725398177,
},
iat: 1725394577,
exp: 1727986577,
jti: "3203d3c7-dc27-4599-b37e-16737b3a6674",
},
apiSession: {
exp: 1725398177,
},
iat: 1725394577,
exp: 1727986577,
jti: "3203d3c7-dc27-4599-b37e-16737b3a6674",
},
};
@@ -1,179 +1,179 @@
.container {
max-width: 50em;
height: fit-content;
max-width: 50em;
height: fit-content;
display: flex;
flex-direction: row;
font-size: 1.4rem;
overflow: hidden;
border-radius: 0.4rem;
border: 2px solid rgba(var(--color-grey-90-rgb), 0.4);
background-color: var(--color-white);
.side__illustration {
display: flex;
flex-direction: column;
justify-content: space-between;
max-width: 16em;
box-sizing: content-box;
opacity: 0.8;
flex-direction: row;
text-shadow: rgba(var(--color-grey-10), 0.3);
padding: 2rem 2rem;
font-size: 1.4rem;
overflow: hidden;
--s: 100px;
--c1: rgba(var(--color-grey-70-rgb), 0.4);
--c2: var(--color-white);
border-radius: 0.4rem;
border: 2px solid rgba(var(--color-grey-90-rgb), 0.4);
background-color: var(--color-white);
--_g: #0000 90deg, var(--c1) 0;
background: conic-gradient(from 90deg at 2px 2px, var(--_g)),
conic-gradient(from 90deg at 1px 1px, var(--_g)), var(--c2);
background-size:
var(--s) var(--s),
calc(var(--s) / 5) calc(var(--s) / 5);
background-position: -2px -2px;
.side__illustration {
display: flex;
flex-direction: column;
justify-content: space-between;
max-width: 16em;
box-sizing: content-box;
opacity: 0.8;
& > h1 {
font-size: 3.4rem;
font-weight: bold;
text-align: left;
text-shadow: rgba(var(--color-grey-10), 0.3);
padding: 2rem 2rem;
padding-right: 2rem;
--s: 100px;
--c1: rgba(var(--color-grey-70-rgb), 0.4);
--c2: var(--color-white);
& > span {
position: relative;
--_g: #0000 90deg, var(--c1) 0;
background: conic-gradient(from 90deg at 2px 2px, var(--_g)),
conic-gradient(from 90deg at 1px 1px, var(--_g)), var(--c2);
background-size:
var(--s) var(--s),
calc(var(--s) / 5) calc(var(--s) / 5);
background-position: -2px -2px;
&::after {
content: "";
position: absolute;
height: 40%;
width: 110%;
top: 70%;
left: -5%;
& > h1 {
font-size: 3.4rem;
font-weight: bold;
text-align: left;
z-index: -1;
padding-right: 2rem;
background-color: var(--color-primary);
}
}
}
& > span {
position: relative;
& > h4 {
font-size: 1.4rem;
&::after {
content: "";
position: absolute;
height: 40%;
width: 110%;
top: 70%;
left: -5%;
text-align: right;
padding-left: 2rem;
}
}
z-index: -1;
.form {
display: flex;
flex-direction: column;
align-items: center;
gap: 1em;
padding: 3.5em 2.5em;
background: rgba(var(--color-grey-90-rgb), 0.4);
&__group {
display: flex;
flex-direction: column;
gap: 0.2em;
}
&__label {
margin-left: 0.8em;
color: rgba(var(--color-grey-60-rgb), 0.8);
transition: all 0.3s;
}
&__input {
padding: 0.8em;
background-color: transparent;
border: none;
border-bottom: 2px solid var(--color-grey-70);
border-radius: 2px;
transition: all 0.2s;
&::placeholder {
opacity: 1;
color: rgba(var(--color-grey-60-rgb), 0.8);
}
&:focus {
outline: none;
border-bottom: 2px solid var(--color-primary);
box-shadow: 0 1px 4px rgba(var(--color-primary-dark-rgb), 0.3);
}
}
&__group:has(.form__input:placeholder-shown) .form__label {
opacity: 0;
transform: translateY(1rem);
}
&__actions {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.5em;
& .submit__button {
all: unset;
width: fit-content;
font-size: 1.4rem;
text-transform: uppercase;
padding: 0.5em 1em;
border-radius: 2px;
align-self: center;
border: none;
color: var(--color-black);
border: 2px solid var(--color-primary);
transition: all 0.2s;
&:hover {
color: var(--color-white);
background-color: var(--color-primary);
cursor: pointer;
transform: translateY(-2px);
box-shadow: 0 2px 6px rgba(var(--color-primary-dark-rgb), 0.4);
background-color: var(--color-primary);
}
}
}
&:active {
transform: translateY(0);
& > h4 {
font-size: 1.4rem;
text-align: right;
padding-left: 2rem;
}
}
}
&__message {
opacity: 0;
height: 2.5rem;
.form {
display: flex;
flex-direction: column;
align-items: center;
gap: 1em;
align-self: flex-start;
padding: 3.5em 2.5em;
transition: opacity 0.3s ease-in;
background: rgba(var(--color-grey-90-rgb), 0.4);
&--wrong {
color: var(--color-error);
}
&__group {
display: flex;
flex-direction: column;
gap: 0.2em;
}
&__label {
margin-left: 0.8em;
color: rgba(var(--color-grey-60-rgb), 0.8);
transition: all 0.3s;
}
&__input {
padding: 0.8em;
background-color: transparent;
border: none;
border-bottom: 2px solid var(--color-grey-70);
border-radius: 2px;
transition: all 0.2s;
&::placeholder {
opacity: 1;
color: rgba(var(--color-grey-60-rgb), 0.8);
}
&:focus {
outline: none;
border-bottom: 2px solid var(--color-primary);
box-shadow: 0 1px 4px rgba(var(--color-primary-dark-rgb), 0.3);
}
}
&__group:has(.form__input:placeholder-shown) .form__label {
opacity: 0;
transform: translateY(1rem);
}
&__actions {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.5em;
& .submit__button {
all: unset;
width: fit-content;
font-size: 1.4rem;
text-transform: uppercase;
padding: 0.5em 1em;
border-radius: 2px;
align-self: center;
border: none;
color: var(--color-black);
border: 2px solid var(--color-primary);
transition: all 0.2s;
&:hover {
color: var(--color-white);
background-color: var(--color-primary);
cursor: pointer;
transform: translateY(-2px);
box-shadow: 0 2px 6px rgba(var(--color-primary-dark-rgb), 0.4);
}
&:active {
transform: translateY(0);
}
}
}
&__message {
opacity: 0;
height: 2.5rem;
align-self: flex-start;
transition: opacity 0.3s ease-in;
&--wrong {
color: var(--color-error);
}
}
}
}
}
.form--error {
.form__input {
border-bottom: 2px solid var(--color-error);
}
.form__input {
border-bottom: 2px solid var(--color-error);
}
.form__message {
opacity: 1;
}
.form__message {
opacity: 1;
}
}
@@ -7,101 +7,101 @@ import { signIn, useSession } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
type LogInWidgetsProps = {
afterSuccess?: Function;
afterSuccess?: Function;
};
export const LogInWidget: React.FC<LogInWidgetsProps> = ({ afterSuccess }) => {
const router = useRouter();
const searchParams = useSearchParams();
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl");
const callbackUrl = searchParams.get("callbackUrl");
const [loginStatus, setLoginStatus] = useState<
"idle" | "check" | "confirm" | "error"
>("idle");
const [loginStatus, setLoginStatus] = useState<
"idle" | "check" | "confirm" | "error"
>("idle");
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setLoginStatus("check");
const username = (
event.currentTarget.elements.namedItem("username") as HTMLInputElement
).value;
const password = (
event.currentTarget.elements.namedItem("password") as HTMLInputElement
).value;
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setLoginStatus("check");
const username = (
event.currentTarget.elements.namedItem("username") as HTMLInputElement
).value;
const password = (
event.currentTarget.elements.namedItem("password") as HTMLInputElement
).value;
const result = await signIn("credentials", {
redirect: false,
username: username,
password: password,
});
const result = await signIn("credentials", {
redirect: false,
username: username,
password: password,
});
if (!result?.ok) {
setLoginStatus("error");
} else {
setLoginStatus("confirm");
}
};
if (!result?.ok) {
setLoginStatus("error");
} else {
setLoginStatus("confirm");
}
};
useEffect(() => {
if (loginStatus === "confirm") {
router.push(callbackUrl ?? "/");
}
}, [loginStatus]);
useEffect(() => {
if (loginStatus === "confirm") {
router.push(callbackUrl ?? "/");
}
}, [loginStatus]);
return (
<div className={styles.container}>
<div className={styles["side__illustration"]}>
<h1>
Welcome to this <span>page!</span>
</h1>
<h4>Use your credentials to log-in</h4>
</div>
<form
className={`${styles.form} ${loginStatus === "error" && styles["form--error"]} ${loginStatus === "check" && styles["form--loading"]}`}
onSubmit={submit}
>
<div className={`${styles["form__group"]}`}>
<label htmlFor="username" className={styles["form__label"]}>
username
</label>
<input
type="text"
id="username"
name="username"
placeholder="username"
className={styles["form__input"]}
required
/>
return (
<div className={styles.container}>
<div className={styles["side__illustration"]}>
<h1>
Welcome to this <span>page!</span>
</h1>
<h4>Use your credentials to log-in</h4>
</div>
<form
className={`${styles.form} ${loginStatus === "error" && styles["form--error"]} ${loginStatus === "check" && styles["form--loading"]}`}
onSubmit={submit}
>
<div className={`${styles["form__group"]}`}>
<label htmlFor="username" className={styles["form__label"]}>
username
</label>
<input
type="text"
id="username"
name="username"
placeholder="username"
className={styles["form__input"]}
required
/>
</div>
<div className={styles["form__group"]}>
<label htmlFor="password" className={styles["form__label"]}>
password
</label>
<input
type="password"
id="password"
name="password"
placeholder="password"
className={styles["form__input"]}
required
/>
</div>
<div className={styles["form__actions"]}>
<div className={styles["form__message"]}>
{loginStatus === "error" ? (
<p className={styles["form__message--wrong"]}>
Wrong credentials, try again
</p>
) : (
<>Loading...</>
)}
</div>
<button type="submit" className={styles["submit__button"]}>
Log-in
</button>
</div>
</form>
</div>
<div className={styles["form__group"]}>
<label htmlFor="password" className={styles["form__label"]}>
password
</label>
<input
type="password"
id="password"
name="password"
placeholder="password"
className={styles["form__input"]}
required
/>
</div>
<div className={styles["form__actions"]}>
<div className={styles["form__message"]}>
{loginStatus === "error" ? (
<p className={styles["form__message--wrong"]}>
Wrong credentials, try again
</p>
) : (
<>Loading...</>
)}
</div>
<button type="submit" className={styles["submit__button"]}>
Log-in
</button>
</div>
</form>
</div>
);
);
};
@@ -2,9 +2,9 @@ import { signIn } from "next-auth/react";
import styles from "./sign-in-button.module.scss";
export const SignInButton = () => {
return (
<button className={styles.button} onClick={() => void signIn()}>
Sign in
</button>
);
return (
<button className={styles.button} onClick={() => void signIn()}>
Sign in
</button>
);
};
@@ -2,12 +2,12 @@ import { signOut } from "next-auth/react";
import styles from "./sign-out-button.module.scss";
export const SignOutButton = () => {
return (
<button
className={styles.button}
onClick={() => void signOut({ callbackUrl: "/" })}
>
Sign out
</button>
);
return (
<button
className={styles.button}
onClick={() => void signOut({ callbackUrl: "/" })}
>
Sign out
</button>
);
};
@@ -3,14 +3,14 @@ import UserPanel from "../user-panel";
import { UserFrame } from "../user-frame";
export const UserDropdown = async () => {
return (
<div className={styles.container}>
<div className={styles["avatar__container"]}>
<UserFrame />
</div>
<div className={styles["dropdown__container"]}>
<UserPanel />
</div>
</div>
);
return (
<div className={styles.container}>
<div className={styles["avatar__container"]}>
<UserFrame />
</div>
<div className={styles["dropdown__container"]}>
<UserPanel />
</div>
</div>
);
};
@@ -1,26 +1,26 @@
.container {
position: relative;
overflow: visible;
position: relative;
overflow: visible;
.avatar__container {
height: var(--header-item-height, 4rem);
width: var(--header-item-height, 4rem);
.avatar__container {
height: var(--header-item-height, 4rem);
width: var(--header-item-height, 4rem);
font-size: 3.2rem;
}
font-size: 3.2rem;
}
.dropdown__container {
position: absolute;
right: 0;
.dropdown__container {
position: absolute;
right: 0;
opacity: 0;
visibility: hidden;
opacity: 0;
visibility: hidden;
transition: all 0.2s;
}
transition: all 0.2s;
}
&:hover > .dropdown__container {
opacity: 1;
visibility: visible;
}
&:hover > .dropdown__container {
opacity: 1;
visibility: visible;
}
}
@@ -5,39 +5,39 @@ import { getServerSession } from "next-auth";
import SignInButton from "../sign-in-button";
export async function UserFrame() {
const session = await getServerSession();
const session = await getServerSession();
if (!session) {
return <SignInButton />;
}
if (!session) {
return <SignInButton />;
}
return (
<div className={styles.frame}>
<span className={styles.initials}>
{getInitialsFromName(session?.user?.name || "00")}
</span>
<Image
className={styles["profile__picture"]}
src={session?.user?.image || ""}
alt="user profile picture"
fill={true}
sizes="(max-width: 768px) 10rem, 6rem"
priority={true}
/>
</div>
);
return (
<div className={styles.frame}>
<span className={styles.initials}>
{getInitialsFromName(session?.user?.name || "00")}
</span>
<Image
className={styles["profile__picture"]}
src={session?.user?.image || ""}
alt="user profile picture"
fill={true}
sizes="(max-width: 768px) 10rem, 6rem"
priority={true}
/>
</div>
);
}
const getInitialsFromName = (name: string): string => {
if (name.length < 1) return "00";
if (name.length < 1) return "00";
// TODO: this can be done in one line probably
const words = name.split(" ");
let initials = "";
words.forEach((word) => (initials += word[0]));
if (initials.length < 2) {
initials += name[1];
}
// TODO: this can be done in one line probably
const words = name.split(" ");
let initials = "";
words.forEach((word) => (initials += word[0]));
if (initials.length < 2) {
initials += name[1];
}
return initials.slice(0, 2);
return initials.slice(0, 2);
};
@@ -1,23 +1,23 @@
.frame {
position: relative;
font-size: inherit;
width: 100%;
height: 100%;
border-radius: 50%;
overflow: hidden;
box-shadow: 1px 1px 3px var(--color-grey-30);
box-sizing: border-box;
& > span {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
position: relative;
font-size: inherit;
text-transform: uppercase;
color: var(--color-grey-30);
}
width: 100%;
height: 100%;
border-radius: 50%;
overflow: hidden;
box-shadow: 1px 1px 3px var(--color-grey-30);
box-sizing: border-box;
& > span {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: inherit;
text-transform: uppercase;
color: var(--color-grey-30);
}
}
@@ -4,20 +4,20 @@ import { useSession } from "next-auth/react";
import ThemeSwitcher from "@/modules/common/components/theme-switcher";
export function UserPanel() {
const session = useSession();
const session = useSession();
return (
<ul className={styles.frame}>
<li className={styles["user__name"]}>{session.data?.user?.name}</li>
<ul className={styles["user__roles"]}>
{session.data?.user?.roles.map((role) => (
<li key={role} className={styles["role__chip"]}>
{role}
</li>
))}
</ul>
<ThemeSwitcher />
<SignOutButton />
</ul>
);
return (
<ul className={styles.frame}>
<li className={styles["user__name"]}>{session.data?.user?.name}</li>
<ul className={styles["user__roles"]}>
{session.data?.user?.roles.map((role) => (
<li key={role} className={styles["role__chip"]}>
{role}
</li>
))}
</ul>
<ThemeSwitcher />
<SignOutButton />
</ul>
);
}
@@ -1,39 +1,39 @@
.frame {
list-style: none;
min-width: 15rem;
display: flex;
flex-direction: column;
gap: 1.8rem 0.6rem;
list-style: none;
min-width: 15rem;
display: flex;
flex-direction: column;
gap: 1.8rem 0.6rem;
padding: 1.2rem 1.8rem;
padding: 1.2rem 1.8rem;
border-radius: 6px;
background-color: var(--color-white);
box-shadow: 0 2px 3px rgba(var(--color-grey-10-rgb), 0.2);
color: var(--color-background);
border-radius: 6px;
background-color: var(--color-white);
box-shadow: 0 2px 3px rgba(var(--color-grey-10-rgb), 0.2);
color: var(--color-background);
z-index: 10;
z-index: 10;
& .user {
font-size: 1.6rem;
& .user {
font-size: 1.6rem;
&__name {
&__name {
}
&__roles {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
& .role__chip {
padding: 0.4rem 0.6rem;
border-radius: 4px;
background-color: var(--color-grey-90);
color: var(--color-grey-10);
}
}
}
&__roles {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
& .role__chip {
padding: 0.4rem 0.6rem;
border-radius: 4px;
background-color: var(--color-grey-90);
color: var(--color-grey-10);
}
}
}
}
+86 -79
View File
@@ -2,85 +2,92 @@ import { AuthOptions, Role, User } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
export const authOptions: AuthOptions = {
session: {
strategy: "jwt",
},
pages: {
signIn: "/auth/log-in",
signOut: "/",
},
providers: [
CredentialsProvider({
name: "Sign in",
credentials: {
username: { label: "Username", type: "text", placeholder: "yourself" },
password: { label: "Password", type: "password" },
},
async authorize(credentials, req) {
const response = await fetch("http://localhost:3000/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: credentials?.username,
password: credentials?.password,
}),
});
type LoginResponse = {
access_token: string;
};
if (response.status < 200 || response.status > 399) return null;
const response_body = (await response.json()) as LoginResponse;
type TokenPayload = {
sub: string;
username: string;
roles: Role[];
picture: string;
iat: number;
exp: number;
};
const token_payload = JSON.parse(
atob(response_body.access_token.split(".")[1]),
) as TokenPayload;
const user: User = {
id: token_payload.username,
roles: token_payload.roles,
image: token_payload.picture,
name: token_payload.username,
apiSession: {
exp: token_payload.exp,
accessToken: response_body.access_token,
},
};
return user;
},
}),
],
callbacks: {
async jwt({ token, user }) {
// Persist the OAuth access_token to the token right after signin
if (user) {
const { apiSession, ...cleanedUser } = user;
token.user = cleanedUser;
token.apiSession = apiSession;
}
return token;
session: {
strategy: "jwt",
},
async session({ session, token }) {
// Send properties to the client, like an access_token from a provider.
if (token?.user && session) {
session.apiSession = token.apiSession;
session.user = token.user;
}
return session;
pages: {
signIn: "/auth/log-in",
signOut: "/",
},
providers: [
CredentialsProvider({
name: "Sign in",
credentials: {
username: {
label: "Username",
type: "text",
placeholder: "yourself",
},
password: { label: "Password", type: "password" },
},
async authorize(credentials, req) {
const response = await fetch(
process.env.NEXT_PUBLIC_AUTH_URL + "/auth/login",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: credentials?.username,
password: credentials?.password,
}),
},
);
type LoginResponse = {
access_token: string;
};
if (response.status < 200 || response.status > 399) return null;
const response_body = (await response.json()) as LoginResponse;
type TokenPayload = {
sub: string;
username: string;
roles: Role[];
picture: string;
iat: number;
exp: number;
};
const token_payload = JSON.parse(
atob(response_body.access_token.split(".")[1]),
) as TokenPayload;
const user: User = {
id: token_payload.username,
roles: token_payload.roles,
image: token_payload.picture,
name: token_payload.username,
apiSession: {
exp: token_payload.exp,
accessToken: response_body.access_token,
},
};
return user;
},
}),
],
callbacks: {
async jwt({ token, user }) {
// Persist the OAuth access_token to the token right after signin
if (user) {
const { apiSession, ...cleanedUser } = user;
token.user = cleanedUser;
token.apiSession = apiSession;
}
return token;
},
async session({ session, token }) {
// Send properties to the client, like an access_token from a provider.
if (token?.user && session) {
session.apiSession = token.apiSession;
session.user = token.user;
}
return session;
},
},
},
};
@@ -4,5 +4,5 @@ import { SessionProvider } from "next-auth/react";
import { PropsWithChildren } from "react";
export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
return <SessionProvider>{children}</SessionProvider>;
return <SessionProvider>{children}</SessionProvider>;
};
+21 -21
View File
@@ -2,29 +2,29 @@ import NextAuth, { DefaultSession } from "next-auth";
import { JWT, DefaultJWT } from "next-auth/jwt";
declare module "next-auth" {
type Role = "user" | "manager" | "admin";
interface ApiSession {
exp?: number;
accessToken: string;
refreshToken?: string;
}
interface User {
id: string;
roles: Role[];
image?: string;
name?: string;
apiSession?: ApiSession;
}
type Role = "user" | "manager" | "admin";
interface ApiSession {
exp?: number;
accessToken: string;
refreshToken?: string;
}
interface User {
id: string;
roles: Role[];
image?: string;
name?: string;
apiSession?: ApiSession;
}
interface Session extends DefaultSession {
user?: Omit<User, "apiSession">;
apiSession?: ApiSession;
}
interface Session extends DefaultSession {
user?: Omit<User, "apiSession">;
apiSession?: ApiSession;
}
}
declare module "next-auth/jwt" {
interface JWT {
user?: Omit<User, "apiSession">;
apiSession?: ApiSession;
}
interface JWT {
user?: Omit<User, "apiSession">;
apiSession?: ApiSession;
}
}
@@ -1,7 +1,7 @@
.container {
min-height: 100vh;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
display: flex;
align-items: center;
justify-content: center;
}
@@ -1,12 +1,15 @@
import { Suspense } from "react";
import { LogInWidget } from "../../components/log-in/log-in.widget";
import styles from "./log-in.module.scss";
type LogInWidgetsProps = {};
export const LogInView: React.FC<LogInWidgetsProps> = ({}) => {
return (
<div className={styles.container}>
<LogInWidget />
</div>
);
return (
<Suspense>
<div className={styles.container}>
<LogInWidget />
</div>
</Suspense>
);
};
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more