Compare commits
4 commits
16698c09d7
...
855274afcc
| Author | SHA1 | Date | |
|---|---|---|---|
| 855274afcc | |||
| e2e6ec541a | |||
| c75dc399d5 | |||
| 99c0ea9752 |
24 changed files with 1182 additions and 152 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -46,3 +46,4 @@ vitest.config.*.timestamp*
|
||||||
|
|
||||||
# Env Files
|
# Env Files
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
|
|
|
||||||
1
apps/backend/drizzle/0001_wet_sumo.sql
Normal file
1
apps/backend/drizzle/0001_wet_sumo.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "users" ADD COLUMN "is_adm" boolean DEFAULT false NOT NULL;
|
||||||
82
apps/backend/drizzle/meta/0001_snapshot.json
Normal file
82
apps/backend/drizzle/meta/0001_snapshot.json
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
{
|
||||||
|
"id": "e64cfe10-4290-4cfd-90a4-fc2680b96ad4",
|
||||||
|
"prevId": "ab4786f8-f96f-4950-b271-caa6ee1c5370",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.users": {
|
||||||
|
"name": "users",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"identity": {
|
||||||
|
"type": "always",
|
||||||
|
"name": "users_id_seq",
|
||||||
|
"schema": "public",
|
||||||
|
"increment": "1",
|
||||||
|
"startWith": "1",
|
||||||
|
"minValue": "1",
|
||||||
|
"maxValue": "2147483647",
|
||||||
|
"cache": "1",
|
||||||
|
"cycle": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"hashed_password": {
|
||||||
|
"name": "hashed_password",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"is_adm": {
|
||||||
|
"name": "is_adm",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,13 @@
|
||||||
"when": 1743489468386,
|
"when": 1743489468386,
|
||||||
"tag": "0000_nosy_khan",
|
"tag": "0000_nosy_khan",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1744477077912,
|
||||||
|
"tag": "0001_wet_sumo",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -61,5 +61,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"jsonwebtoken": "^9.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cookie-parser": "^1.4.8",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
|
"ts-node-dev": "^2.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
const db = drizzle({
|
const db = drizzle({
|
||||||
connection: {
|
connection: {
|
||||||
connectionString: process.env.DATABASE_URL!,
|
connectionString: process.env.DATABASE_URL!,
|
||||||
ssl: true,
|
ssl: process.env.NODE_ENV == 'production',
|
||||||
},
|
},
|
||||||
|
schema,
|
||||||
});
|
});
|
||||||
|
|
||||||
export default db;
|
export default db;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { integer, pgTable, varchar } from 'drizzle-orm/pg-core';
|
import { integer, pgTable, varchar, boolean } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
export const usersTable = pgTable('users', {
|
export const usersTable = pgTable('users', {
|
||||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
name: varchar({ length: 255 }).notNull(),
|
name: varchar({ length: 255 }).notNull(),
|
||||||
email: varchar({ length: 255 }).notNull().unique(),
|
email: varchar({ length: 255 }).notNull().unique(),
|
||||||
hashed_password: varchar({ length: 255 }).notNull(),
|
hashed_password: varchar({ length: 255 }).notNull(),
|
||||||
|
is_adm: boolean().notNull().default(false),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,21 @@
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import cors from 'cors';
|
||||||
|
|
||||||
const host = process.env.HOST ?? 'localhost';
|
import authRouter from './services/auth';
|
||||||
const port = process.env.PORT ? Number(process.env.PORT) : 3000;
|
|
||||||
|
const HOST = process.env.HOST ?? 'localhost';
|
||||||
|
const PORT = process.env.PORT ? Number(process.env.PORT) : 3000;
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
app.get('/', (req, res) => {
|
app.use(cookieParser());
|
||||||
res.send({ message: 'Hello API' });
|
app.use(cors({ origin: 'http://localhost:4200', credentials: true })); // for dev purposes because it will be the same server on production?
|
||||||
});
|
app.use(express.json());
|
||||||
|
|
||||||
app.listen(port, host, () => {
|
app.use(`/auth`, authRouter);
|
||||||
console.log(`[ ready ] http://${host}:${port}`);
|
|
||||||
|
app.listen(PORT, HOST, () => {
|
||||||
|
console.log(`[ ready ] http://${HOST}:${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
141
apps/backend/src/services/auth.ts
Normal file
141
apps/backend/src/services/auth.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import type { StringValue } from "ms";
|
||||||
|
|
||||||
|
import db from '../db';
|
||||||
|
import { usersTable } from '../db/schema';
|
||||||
|
|
||||||
|
import { User } from '../types';
|
||||||
|
|
||||||
|
import { argon2id, hash, verify } from 'argon2';
|
||||||
|
|
||||||
|
const PASS_SECRET: string = process.env.SECRET || 'debug_secret';
|
||||||
|
const ACCESS_SECRET = process.env.ACCESS_SECRET || 'access-secret';
|
||||||
|
const REFRESH_SECRET = process.env.REFRESH_SECRET || 'refresh-secret';
|
||||||
|
|
||||||
|
const refreshTokens = new Set<string>(); // ideally store in DB
|
||||||
|
|
||||||
|
const generateToken = (user: User, expiresIn: StringValue = '15s', secret: string = ACCESS_SECRET): string => {
|
||||||
|
return jwt.sign(
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
},
|
||||||
|
secret,
|
||||||
|
{ expiresIn }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// TODO: organize in services folder
|
||||||
|
router.post('/login', async (req: Request, res: Response) => {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
if (typeof email !== 'string' || typeof password !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Invalid request' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await db.query.usersTable.findFirst({
|
||||||
|
where: eq(usersTable.email, email),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
user == null ||
|
||||||
|
!(await verify(user.hashed_password, password, {
|
||||||
|
// type: argon2id,
|
||||||
|
secret: Buffer.from(PASS_SECRET),
|
||||||
|
}))
|
||||||
|
) {
|
||||||
|
res.sendStatus(400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshToken = generateToken(user, '7d', REFRESH_SECRET);
|
||||||
|
refreshTokens.add(refreshToken);
|
||||||
|
|
||||||
|
res.cookie('refreshToken', refreshToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: false,
|
||||||
|
sameSite: 'strict',
|
||||||
|
path: '/refresh',
|
||||||
|
});
|
||||||
|
|
||||||
|
const accessToken = generateToken(user);
|
||||||
|
res.json({ accessToken });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/refresh', (req: Request, res: Response) => {
|
||||||
|
const token = req.cookies?.refreshToken;
|
||||||
|
|
||||||
|
// voltar à verificação abaixo, quando refresh tokens estiver em DB
|
||||||
|
if (!token /*|| !refreshTokens.has(token)*/) return res.sendStatus(403);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = jwt.verify(token, REFRESH_SECRET) as User;
|
||||||
|
const accessToken = generateToken(user);
|
||||||
|
res.json({ accessToken });
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
res.sendStatus(403);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/logout', (req: Request, res: Response) => {
|
||||||
|
const token = req.cookies?.refreshToken;
|
||||||
|
if (token) refreshTokens.delete(token);
|
||||||
|
|
||||||
|
res.clearCookie('refreshToken', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: false,
|
||||||
|
sameSite: 'strict',
|
||||||
|
path: '/refresh',
|
||||||
|
});
|
||||||
|
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
const verifyAuth = (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
const token = authHeader?.split(' ')[1];
|
||||||
|
|
||||||
|
if (!token) return res.sendStatus(401);
|
||||||
|
|
||||||
|
try {
|
||||||
|
res.locals.user = jwt.verify(token, ACCESS_SECRET) as User;
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.sendStatus(401);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
router.get('/protected', verifyAuth, (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const user = res.locals.user;
|
||||||
|
res.json({ message: `Hello ${user.name}` });
|
||||||
|
} catch {
|
||||||
|
res.sendStatus(403);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// for development purposes - REMOVE
|
||||||
|
router.get('/hash', async (req: Request, res: Response) => {
|
||||||
|
const { password } = req.query;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hashed_pass = await hash(password as string, {
|
||||||
|
type: argon2id,
|
||||||
|
secret: Buffer.from(PASS_SECRET),
|
||||||
|
});
|
||||||
|
console.log(`hashed ${hashed_pass}`);
|
||||||
|
|
||||||
|
res.sendStatus(200);
|
||||||
|
} catch (ex) {
|
||||||
|
res.sendStatus(500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
4
apps/backend/src/types.d.ts
vendored
Normal file
4
apps/backend/src/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
46
apps/frontend/src/App.tsx
Normal file
46
apps/frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Route, Routes, Navigate } from 'react-router';
|
||||||
|
import { useAuthStore } from './auth/authStore';
|
||||||
|
import { refreshToken } from './auth/auth-utils';
|
||||||
|
|
||||||
|
/* Components */
|
||||||
|
import LoginPage from './components/pages/LoginPage';
|
||||||
|
import TemplatePage from './components/pages/TemplatePage';
|
||||||
|
import HomePage from './components/pages/HomePage';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const accessToken = useAuthStore((s) => s.accessToken);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const init = async () => {
|
||||||
|
try {
|
||||||
|
await refreshToken();
|
||||||
|
} catch {}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
init();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <div>Loading...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
{!accessToken ? (
|
||||||
|
<>
|
||||||
|
{/* Unauthenticated routes */}
|
||||||
|
<Route path="login" element={<LoginPage />} />
|
||||||
|
{/* Redirect other routes to login */}
|
||||||
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Route element={<TemplatePage />}>
|
||||||
|
{/* Authenticated routes */}
|
||||||
|
<Route path="/" element={<HomePage />} />
|
||||||
|
{/* Redirect other routes to the home */}
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Route>
|
||||||
|
)}
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
apps/frontend/src/auth/auth-utils.ts
Normal file
30
apps/frontend/src/auth/auth-utils.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { useAuthStore } from './authStore';
|
||||||
|
|
||||||
|
export function getAccessToken(): string | null {
|
||||||
|
return useAuthStore.getState().accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAccessToken(token: string | null) {
|
||||||
|
useAuthStore.getState().setAccessToken(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshToken(): Promise<string> {
|
||||||
|
const res = await fetch(`${import.meta.env.VITE_SERVER}/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error('Unable to refresh token');
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
setAccessToken(data.accessToken);
|
||||||
|
return data.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
await fetch(`${import.meta.env.VITE_SERVER}/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
setAccessToken(null);
|
||||||
|
}
|
||||||
11
apps/frontend/src/auth/authStore.ts
Normal file
11
apps/frontend/src/auth/authStore.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
type AuthState = {
|
||||||
|
accessToken: string | null;
|
||||||
|
setAccessToken: (token: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
|
accessToken: null,
|
||||||
|
setAccessToken: (token) => set({ accessToken: token }),
|
||||||
|
}));
|
||||||
38
apps/frontend/src/auth/axiosInstance.ts
Normal file
38
apps/frontend/src/auth/axiosInstance.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import axios, { AxiosError } from 'axios';
|
||||||
|
import { getAccessToken, refreshToken } from './auth-utils';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_SERVER,
|
||||||
|
withCredentials: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
async (err: AxiosError) => {
|
||||||
|
const originalRequest = err.config as any;
|
||||||
|
if (
|
||||||
|
(err.response?.status === 401 || err.response?.status === 403) &&
|
||||||
|
!originalRequest._retry
|
||||||
|
) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
try {
|
||||||
|
const newToken = await refreshToken();
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||||
|
return api(originalRequest);
|
||||||
|
} catch {
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
12
apps/frontend/src/components/inputs/Button.tsx
Normal file
12
apps/frontend/src/components/inputs/Button.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface ButtonProps {
|
||||||
|
label?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextField: React.FC<ButtonProps> = ({ label = 'button', onClick }) => {
|
||||||
|
return (<button className="border my-5 bg-gray-50 hover:bg-gray-500 hover:text-white rounded-lg p-2 font-bold" onClick={onClick}>{label}</button>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TextField;
|
||||||
28
apps/frontend/src/components/inputs/TextField.tsx
Normal file
28
apps/frontend/src/components/inputs/TextField.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface InputTextProps {
|
||||||
|
label?: string;
|
||||||
|
inputType?: 'text' | 'password';
|
||||||
|
value?: string;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TextField: React.FC<InputTextProps> = ({ inputType = 'text', label = '', name, value, onChange }) => {
|
||||||
|
return (<div className="flex flex-col">
|
||||||
|
{label && (<div className={`font-semibold text-black`}>
|
||||||
|
{label}
|
||||||
|
</div>)}
|
||||||
|
<input
|
||||||
|
type={inputType}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
className="text-black-1 border disabled:!bg-gray-2 disabled:!border-gray-2 rounded-lg h-10 p-2"
|
||||||
|
/>
|
||||||
|
</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TextField;
|
||||||
25
apps/frontend/src/components/pages/HomePage.tsx
Normal file
25
apps/frontend/src/components/pages/HomePage.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import api from '../../auth/axiosInstance';
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-4'>
|
||||||
|
<p>Welcome to Fediswald!</p>
|
||||||
|
<p>
|
||||||
|
<Protected />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Protected() {
|
||||||
|
const callAPI = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/auth/protected');
|
||||||
|
alert(res.data.message);
|
||||||
|
} catch {
|
||||||
|
alert('Unauthorized');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return <button onClick={callAPI}>Call Protected Route</button>;
|
||||||
|
}
|
||||||
52
apps/frontend/src/components/pages/LoginPage.tsx
Normal file
52
apps/frontend/src/components/pages/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useAuthStore } from '../../auth/authStore';
|
||||||
|
|
||||||
|
/* Components */
|
||||||
|
import TextField from '../inputs/TextField';
|
||||||
|
import Button from '../inputs/Button';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const setAccessToken = useAuthStore((s) => s.setAccessToken);
|
||||||
|
|
||||||
|
const [email, _email] = useState('');
|
||||||
|
const [password, _password] = useState('');
|
||||||
|
|
||||||
|
const login = async () => {
|
||||||
|
const res = await fetch(`${import.meta.env.VITE_SERVER}/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setAccessToken(data.accessToken);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (<div className="flex flex-col grow max-h-screen overflow-hidden p-10">
|
||||||
|
<main className="flex flex-col grow h-full w-1/3">
|
||||||
|
<TextField
|
||||||
|
label='E-mail'
|
||||||
|
name="email"
|
||||||
|
value={email}
|
||||||
|
onChange={_email}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label='Password'
|
||||||
|
inputType="password"
|
||||||
|
name="password"
|
||||||
|
value={password}
|
||||||
|
onChange={_password}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button onClick={login} label="Login" />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/frontend/src/components/pages/TemplatePage.tsx
Normal file
25
apps/frontend/src/components/pages/TemplatePage.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { Outlet } from "react-router";
|
||||||
|
import { NavLink } from "react-router";
|
||||||
|
|
||||||
|
import { logout } from '../../auth/auth-utils';
|
||||||
|
|
||||||
|
export default function TemplatePage() {
|
||||||
|
|
||||||
|
return (<div className="flex flex-col grow max-h-screen overflow-hidden">
|
||||||
|
<div className="flex justify-between p-md bg-white border-b border-gray-2 py-2 px-5">
|
||||||
|
{/* <img src={logo} alt="Fediswald" className="h-lg w-lg" /> */}
|
||||||
|
<div className="flex gap-lg">
|
||||||
|
<NavLink to="/" end>
|
||||||
|
Home
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-sm border-gray-2 pl-sm">
|
||||||
|
{/* <div>{user.name}</div> */}
|
||||||
|
<button onClick={logout}>Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<main className="flex grow h-full p-5">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router';
|
||||||
import * as ReactDOM from 'react-dom/client';
|
import * as ReactDOM from 'react-dom/client';
|
||||||
import App from './app/app';
|
import App from './App';
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(
|
const root = ReactDOM.createRoot(
|
||||||
document.getElementById('root') as HTMLElement
|
document.getElementById('root') as HTMLElement
|
||||||
|
|
|
||||||
779
package-lock.json
generated
779
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,10 @@
|
||||||
"name": "@fediswald/source",
|
"name": "@fediswald/source",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {},
|
"scripts": {
|
||||||
|
"backend": "npx nx serve backend",
|
||||||
|
"frontend": "npx nx serve frontend"
|
||||||
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fedify/fedify": "^1.5.0",
|
"@fedify/fedify": "^1.5.0",
|
||||||
|
|
@ -14,7 +17,7 @@
|
||||||
"pg": "^8.14.1",
|
"pg": "^8.14.1",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
"react-router-dom": "6.29.0",
|
"react-router": "^7.5.2",
|
||||||
"zod": "^3.24.2",
|
"zod": "^3.24.2",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
"noEmitOnError": true,
|
"noEmitOnError": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noImplicitReturns": true,
|
"noImplicitReturns": false,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|
|
||||||
Reference in a new issue