Did more stuff than I even wanna write. Notably:
- Auth/account management - Navigation system - Admin views for LOA stuff
This commit is contained in:
26
api/index.js
26
api/index.js
@@ -13,7 +13,28 @@ app.use(cors({
|
|||||||
|
|
||||||
app.use(express.json())
|
app.use(express.json())
|
||||||
|
|
||||||
const port = 3000;
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
|
const port = process.env.SERVER_PORT;
|
||||||
|
|
||||||
|
//session setup
|
||||||
|
const path = require('path')
|
||||||
|
const session = require('express-session')
|
||||||
|
const passport = require('passport')
|
||||||
|
const SQLiteStore = require('connect-sqlite3')(session);
|
||||||
|
|
||||||
|
app.use(session({
|
||||||
|
secret: 'whatever',
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
store: new SQLiteStore({ db: 'sessions.db', dir: './' }),
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
domain: 'nexuszone.net'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
app.use(passport.authenticate('session'));
|
||||||
|
|
||||||
// Mount route modules
|
// Mount route modules
|
||||||
const applicationsRouter = require('./routes/applications');
|
const applicationsRouter = require('./routes/applications');
|
||||||
@@ -21,6 +42,7 @@ const { memberRanks, ranks } = require('./routes/ranks');
|
|||||||
const members = require('./routes/members');
|
const members = require('./routes/members');
|
||||||
const loaHandler = require('./routes/loa')
|
const loaHandler = require('./routes/loa')
|
||||||
const { status, memberStatus } = require('./routes/statuses')
|
const { status, memberStatus } = require('./routes/statuses')
|
||||||
|
const authRouter = require('./routes/auth')
|
||||||
|
|
||||||
app.use('/application', applicationsRouter);
|
app.use('/application', applicationsRouter);
|
||||||
app.use('/ranks', ranks);
|
app.use('/ranks', ranks);
|
||||||
@@ -29,6 +51,8 @@ app.use('/members', members);
|
|||||||
app.use('/loa', loaHandler);
|
app.use('/loa', loaHandler);
|
||||||
app.use('/status', status)
|
app.use('/status', status)
|
||||||
app.use('/memberStatus', memberStatus)
|
app.use('/memberStatus', memberStatus)
|
||||||
|
app.use('/', authRouter)
|
||||||
|
|
||||||
app.get('/ping', (req, res) => {
|
app.get('/ping', (req, res) => {
|
||||||
res.status(200).json({ message: 'pong' });
|
res.status(200).json({ message: 'pong' });
|
||||||
});
|
});
|
||||||
|
|||||||
1515
api/package-lock.json
generated
1515
api/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,10 +10,14 @@
|
|||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"connect-sqlite3": "^0.9.16",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.1",
|
"dotenv": "^17.2.1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"express-session": "^1.18.2",
|
||||||
"mariadb": "^3.4.5",
|
"mariadb": "^3.4.5",
|
||||||
"mysql2": "^3.14.3"
|
"mysql2": "^3.14.3",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-openidconnect": "^0.1.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
110
api/routes/auth.js
Normal file
110
api/routes/auth.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
const passport = require('passport');
|
||||||
|
const OpenIDConnectStrategy = require('passport-openidconnect');
|
||||||
|
const dotenv = require('dotenv');
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { param } = require('./applications');
|
||||||
|
const router = express.Router();
|
||||||
|
const pool = require('../db')
|
||||||
|
|
||||||
|
passport.use(new OpenIDConnectStrategy({
|
||||||
|
issuer: process.env.AUTH_ISSUER,
|
||||||
|
authorizationURL: 'https://sso.iceberg-gaming.com/application/o/authorize/',
|
||||||
|
tokenURL: 'https://sso.iceberg-gaming.com/application/o/token/',
|
||||||
|
userInfoURL: 'https://sso.iceberg-gaming.com/application/o/userinfo/',
|
||||||
|
clientID: process.env.AUTH_CLIENT_ID,
|
||||||
|
clientSecret: process.env.AUTH_CLIENT_SECRET,
|
||||||
|
callbackURL: process.env.AUTH_REDIRECT_URI,
|
||||||
|
scope: ['openid', 'profile']
|
||||||
|
}, async function verify(issuer, sub, profile, jwtClaims, accessToken, refreshToken, params, cb) {
|
||||||
|
|
||||||
|
console.log('--- OIDC verify() called ---');
|
||||||
|
console.log('issuer:', issuer);
|
||||||
|
console.log('sub:', sub);
|
||||||
|
console.log('profile:', JSON.stringify(profile, null, 2));
|
||||||
|
console.log('id_token claims:', JSON.stringify(jwtClaims, null, 2));
|
||||||
|
console.log('preferred_username:', jwtClaims?.preferred_username);
|
||||||
|
|
||||||
|
const con = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await con.beginTransaction();
|
||||||
|
|
||||||
|
//lookup existing user
|
||||||
|
const existing = await con.query(`SELECT id FROM members WHERE authentik_issuer = ? AND authentik_sub = ? LIMIT 1;`, [issuer, sub]);
|
||||||
|
console.log(existing)
|
||||||
|
let memberId;
|
||||||
|
//if member exists
|
||||||
|
if (existing.length > 0) {
|
||||||
|
console.log('member exists');
|
||||||
|
memberId = existing[0].id;
|
||||||
|
} else {
|
||||||
|
console.log("creating member")
|
||||||
|
//otherwise: create account
|
||||||
|
const username = sub.username;
|
||||||
|
|
||||||
|
const result = await con.query(
|
||||||
|
`INSERT INTO members (name, authentik_sub, authentik_issuer) VALUES (?, ?, ?)`,
|
||||||
|
[username, sub, issuer]
|
||||||
|
)
|
||||||
|
memberId = result.insertId;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("hello world" + memberId);
|
||||||
|
await con.commit();
|
||||||
|
return cb(null, { memberId });
|
||||||
|
} catch (error) {
|
||||||
|
await con.rollback();
|
||||||
|
return cb(error);
|
||||||
|
} finally {
|
||||||
|
con.release();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.get('/login', passport.authenticate('openidconnect'))
|
||||||
|
router.get('/callback', passport.authenticate('openidconnect', {
|
||||||
|
successRedirect: 'https://aj17thdev.nexuszone.net/',
|
||||||
|
failureRedirect: 'https://aj17thdev.nexuszone.net/'
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/logout', function (req, res, next) {
|
||||||
|
req.logout(function (err) {
|
||||||
|
if (err) { return next(err); }
|
||||||
|
var params = {
|
||||||
|
client_id: process.env.AUTH_CLIENT_ID,
|
||||||
|
returnTo: 'https://aj17thdev.nexuszone.net/'
|
||||||
|
};
|
||||||
|
res.redirect(process.env.AUTH_DOMAIN + '/v2/logout?' + qs.stringify(params));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
passport.serializeUser(function (user, cb) {
|
||||||
|
process.nextTick(function () {
|
||||||
|
console.log(`serialize: ${user.memberId}`);
|
||||||
|
cb(null, user);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
passport.deserializeUser(function (user, cb) {
|
||||||
|
process.nextTick(async function () {
|
||||||
|
const memberID = user.memberId;
|
||||||
|
|
||||||
|
const con = await pool.getConnection();
|
||||||
|
|
||||||
|
var userData;
|
||||||
|
try {
|
||||||
|
userResults = await con.query(`SELECT id, name FROM members WHERE id = ?;`, [memberID])
|
||||||
|
console.log(userResults)
|
||||||
|
userData = userResults[0];
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
con.release();
|
||||||
|
}
|
||||||
|
return cb(null, userData);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -40,4 +40,18 @@ router.get("/me", async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.get('/all', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT loa.*, members.name
|
||||||
|
FROM leave_of_absences AS loa
|
||||||
|
INNER JOIN members ON loa.member_id = members.id;
|
||||||
|
`);
|
||||||
|
res.status(200).json(result)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
res.status(500).send(error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
46
api/routes/statuses.js
Normal file
46
api/routes/statuses.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const status = express.Router();
|
||||||
|
const memberStatus = express.Router();
|
||||||
|
|
||||||
|
const pool = require('../db');
|
||||||
|
|
||||||
|
//insert a new latest rank for a user
|
||||||
|
memberStatus.post('/', async (req, res) => {
|
||||||
|
// try {
|
||||||
|
// const App = req.body?.App || {};
|
||||||
|
|
||||||
|
// // TODO: replace with current user ID
|
||||||
|
// const memberId = 1;
|
||||||
|
|
||||||
|
// const sql = `INSERT INTO applications (member_id, app_version, app_data) VALUES (?, ?, ?);`;
|
||||||
|
// const appVersion = 1;
|
||||||
|
|
||||||
|
// const params = [memberId, appVersion, JSON.stringify(App)]
|
||||||
|
|
||||||
|
// console.log(params)
|
||||||
|
|
||||||
|
// await pool.query(sql, params);
|
||||||
|
|
||||||
|
// res.sendStatus(201);
|
||||||
|
// } catch (err) {
|
||||||
|
// console.error('Insert failed:', err);
|
||||||
|
// res.status(500).json({ error: 'Failed to save application' });
|
||||||
|
// }
|
||||||
|
res.status(501).json({ error: 'Not implemented' });
|
||||||
|
});
|
||||||
|
|
||||||
|
//get all statuses
|
||||||
|
status.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query('SELECT * FROM statuses;');
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports.status = status;
|
||||||
|
module.exports.memberStatus = memberStatus;
|
||||||
|
|
||||||
|
// TODO, implement get all ranks route with SQL stirng SELECT id, name, short_name, category, sort_id FROM ranks;
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// DB pool (same as used in api/index.js)
|
|
||||||
const pool = require('../db');
|
|
||||||
|
|
||||||
//create a new user?
|
|
||||||
router.post('/', async (req, res) => {
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
//get all users
|
|
||||||
router.get('/', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const result = await pool.query('SELECT * FROM view_member_rank_status_all;');
|
|
||||||
return res.status(200).json(result);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching users:', err);
|
|
||||||
return res.status(500).json({ error: 'Failed to fetch users' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/:id', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const userId = req.params.id;
|
|
||||||
const result = await pool.query('SELECT * FROM view_member_rank_status_all WHERE id = $1;', [userId]);
|
|
||||||
if (result.rows.length === 0) {
|
|
||||||
return res.status(404).json({ error: 'User not found' });
|
|
||||||
}
|
|
||||||
return res.status(200).json(result.rows[0]);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching user:', err);
|
|
||||||
return res.status(500).json({ error: 'Failed to fetch user' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
//update a user's display name (stub)
|
|
||||||
router.put('/:id/displayname', async (req, res) => {
|
|
||||||
// Stub: not implemented yet
|
|
||||||
return res.status(501).json({ error: 'Update display name not implemented' });
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -2,24 +2,99 @@
|
|||||||
import { RouterLink, RouterView } from 'vue-router';
|
import { RouterLink, RouterView } from 'vue-router';
|
||||||
import Separator from './components/ui/separator/Separator.vue';
|
import Separator from './components/ui/separator/Separator.vue';
|
||||||
import Button from './components/ui/button/Button.vue';
|
import Button from './components/ui/button/Button.vue';
|
||||||
import Application from './pages/Application.vue';
|
import { Popover, PopoverContent, PopoverTrigger } from './components/ui/popover';
|
||||||
import AutoForm from './components/form/AutoForm.vue';
|
import {
|
||||||
import ManageApplications from './pages/ManageApplications.vue';
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from './components/ui/dropdown-menu';
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
import { useUserStore } from './stores/user';
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const res = await fetch(`${import.meta.env.VITE_APIHOST}/members/me`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
userStore.user = data;
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="h-15 flex items-center justify-center gap-20">
|
<div class="flex items-center justify-between px-10">
|
||||||
<Button variant="link">Link</Button>
|
<div></div>
|
||||||
<Button variant="link">Link</Button>
|
<div class="h-15 flex items-center justify-center gap-20">
|
||||||
<Button variant="link">Link</Button>
|
<RouterLink to="/">
|
||||||
<Button variant="link">Link</Button>
|
<Button variant="link">Home</Button>
|
||||||
|
</RouterLink>
|
||||||
|
<!-- <RouterLink to="/">
|
||||||
|
<Button variant="link">Calendar</Button>
|
||||||
|
</RouterLink> -->
|
||||||
|
<RouterLink to="/members">
|
||||||
|
<Button variant="link">Members</Button>
|
||||||
|
</RouterLink>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<Button variant="link">Forms</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="flex flex-col gap-4 items-center w-min">
|
||||||
|
<RouterLink to="/transfer">
|
||||||
|
<Button variant="link">Transfer Request</Button>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/trainingReport">
|
||||||
|
<Button variant="link">Training Report</Button>
|
||||||
|
</RouterLink>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<Button variant="link">Administration</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="flex flex-col gap-4 items-center w-min">
|
||||||
|
<RouterLink to="/administration/rankChange">
|
||||||
|
<Button variant="link">Promotions</Button>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/administration/loa">
|
||||||
|
<Button variant="link">Leave of Absence</Button>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/administration/transfer">
|
||||||
|
<Button variant="link">Transfer Requests</Button>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink to="/administration/applications">
|
||||||
|
<Button variant="link">Recruitment</Button>
|
||||||
|
</RouterLink>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<DropdownMenu v-if="userStore.isLoggedIn">
|
||||||
|
<DropdownMenuTrigger class="cursor-pointer">
|
||||||
|
<p>Profile</p>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem>My Profile</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>Settings</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<RouterLink to="/loa">
|
||||||
|
Submit LOA
|
||||||
|
</RouterLink>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem :variant="'destructive'">Logout</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<a v-else href="https://aj17thdevapi.nexuszone.net/login">Login</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator></Separator>
|
<Separator></Separator>
|
||||||
<!-- <Application></Application> -->
|
|
||||||
<!-- <ManageApplications></ManageApplications> -->
|
<RouterView></RouterView>
|
||||||
<!-- <AutoForm class="max-w-3xl mx-auto my-20"></AutoForm> -->
|
|
||||||
<RouterView></RouterView>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export type LOARequest = {
|
export type LOARequest = {
|
||||||
|
id: number;
|
||||||
|
name?: string;
|
||||||
member_id: number;
|
member_id: number;
|
||||||
filed_date: string; // ISO 8601 string
|
filed_date: string; // ISO 8601 string
|
||||||
start_date: string; // ISO 8601 string
|
start_date: string; // ISO 8601 string
|
||||||
@@ -43,3 +45,18 @@ export async function getMyLOA(): Promise<LOARequest | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAllLOAs(): Promise<LOARequest[]> {
|
||||||
|
return fetch(`${addr}/loa/all`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
}).then((res) => {
|
||||||
|
if (res.ok) {
|
||||||
|
return res.json();
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
34
ui/src/api/status.ts
Normal file
34
ui/src/api/status.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export type Status = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
created_at: string; // datetime as ISO string
|
||||||
|
updated_at: string; // datetime as ISO string
|
||||||
|
deleted?: boolean; // tinyint, optional if nullable
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const addr = import.meta.env.VITE_APIHOST;
|
||||||
|
|
||||||
|
export async function getAllStatuses(): Promise<Status[]> {
|
||||||
|
const res = await fetch(`${addr}/status`)
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
return res.json()
|
||||||
|
} else {
|
||||||
|
console.error("Something went wrong getting statuses")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignStatus(userId: number, statusId: number, rankId: number): Promise<void> {
|
||||||
|
const res = await fetch(`${addr}/memberStatus`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ userId, statusId, rankId })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error("Something went wrong assigning the status")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,12 +91,12 @@ async function handleSubmit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toMariaDBDatetime(date: Date): string {
|
function toMariaDBDatetime(date: Date): string {
|
||||||
return date.toISOString().slice(0, 19).replace('T', ' ');
|
return date.toISOString().slice(0, 19).replace('T', ' ');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-row-reverse gap-6 mx-auto m-10" :class="!adminMode ? 'max-w-5xl' : 'max-w-2xl'">
|
<div class="flex flex-row-reverse gap-6 mx-auto " :class="!adminMode ? 'max-w-5xl' : 'max-w-2xl'">
|
||||||
<div v-if="!adminMode" class="flex-1 flex space-x-4 rounded-md border p-4">
|
<div v-if="!adminMode" class="flex-1 flex space-x-4 rounded-md border p-4">
|
||||||
<div class="flex-2 space-y-1">
|
<div class="flex-2 space-y-1">
|
||||||
<p class="text-sm font-medium leading-none">
|
<p class="text-sm font-medium leading-none">
|
||||||
@@ -156,11 +156,7 @@ function toMariaDBDatetime(date: Date): string {
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea v-model="reason" placeholder="Reason for LOA" class="w-full resize-none" />
|
||||||
v-model="reason"
|
|
||||||
placeholder="Reason for LOA"
|
|
||||||
class="w-full resize-none"
|
|
||||||
/>
|
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<Button :onClick="handleSubmit" :disabled="submitting" class="w-min">Submit</Button>
|
<Button :onClick="handleSubmit" :disabled="submitting" class="w-min">Submit</Button>
|
||||||
</div>
|
</div>
|
||||||
59
ui/src/components/loa/loaList.vue
Normal file
59
ui/src/components/loa/loaList.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCaption,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { getAllLOAs, LOARequest } from "@/api/loa";
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
|
||||||
|
const LOAList = ref<LOARequest[]>([]);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
LOAList.value = await getAllLOAs();
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
if (!dateStr) return "";
|
||||||
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-5xl mx-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead class="w-[100px]">Member</TableHead>
|
||||||
|
<TableHead>Start</TableHead>
|
||||||
|
<TableHead>End</TableHead>
|
||||||
|
<TableHead>Reason</TableHead>
|
||||||
|
<TableHead>Posted on</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow
|
||||||
|
v-for="post in LOAList"
|
||||||
|
:key="post.id"
|
||||||
|
class="hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<TableCell class="font-medium">
|
||||||
|
{{ post.name }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{{ formatDate(post.start_date) }}</TableCell>
|
||||||
|
<TableCell>{{ formatDate(post.end_date) }}</TableCell>
|
||||||
|
<TableCell>{{ post.reason }}</TableCell>
|
||||||
|
<TableCell>{{ formatDate(post.filed_date) }}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
25
ui/src/components/ui/badge/Badge.vue
Normal file
25
ui/src/components/ui/badge/Badge.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { Primitive } from "reka-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { badgeVariants } from ".";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: [String, Object, Function], required: false },
|
||||||
|
variant: { type: null, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
data-slot="badge"
|
||||||
|
:class="cn(badgeVariants({ variant }), props.class)"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
24
ui/src/components/ui/badge/index.js
Normal file
24
ui/src/components/ui/badge/index.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { cva } from "class-variance-authority";
|
||||||
|
|
||||||
|
export { default as Badge } from "./Badge.vue";
|
||||||
|
|
||||||
|
export const badgeVariants = cva(
|
||||||
|
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
13
ui/src/pages/ManageLOA.vue
Normal file
13
ui/src/pages/ManageLOA.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import LoaForm from '@/components/loa/loaForm.vue';
|
||||||
|
import LoaList from '@/components/loa/loaList.vue';
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="max-w-5xl mx-auto pt-10">
|
||||||
|
<!-- <LoaForm class="m-10"></LoaForm> -->
|
||||||
|
<h1>LOA Log</h1>
|
||||||
|
<LoaList></LoaList>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
3
ui/src/pages/SubmitLOA.vue
Normal file
3
ui/src/pages/SubmitLOA.vue
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<LoaForm class="m-10"></LoaForm>
|
||||||
|
</template>
|
||||||
95
ui/src/pages/Transfer.vue
Normal file
95
ui/src/pages/Transfer.vue
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Check, Search } from "lucide-vue-next"
|
||||||
|
import { Combobox, ComboboxAnchor, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxItemIndicator, ComboboxList } from "@/components/ui/combobox"
|
||||||
|
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { Member, getMembers } from "@/api/member";
|
||||||
|
import Button from "@/components/ui/button/Button.vue";
|
||||||
|
import { Status, getAllStatuses, assignStatus } from "@/api/status";
|
||||||
|
import { Rank, getRanks } from "@/api/rank";
|
||||||
|
|
||||||
|
const members = ref<Member[]>([])
|
||||||
|
const statuses = ref<Status[]>([])
|
||||||
|
const allRanks = ref<Rank[]>([])
|
||||||
|
|
||||||
|
const currentMember = ref<Member | null>(null);
|
||||||
|
const currentStatus = ref<Status | null>(null);
|
||||||
|
const currentRank = ref<Rank | null>(null);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
members.value = await getMembers();
|
||||||
|
statuses.value = await getAllStatuses();
|
||||||
|
allRanks.value = await getRanks();
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-row gap-6 mx-auto m-10 max-w-5xl">
|
||||||
|
<Combobox v-model="currentMember">
|
||||||
|
<ComboboxAnchor class="w-[300px]">
|
||||||
|
<ComboboxInput placeholder="Search members..." class="w-full pl-9"
|
||||||
|
:display-value="(v) => v ? v.member_name : ''" />
|
||||||
|
</ComboboxAnchor>
|
||||||
|
<ComboboxList class="w-[300px]">
|
||||||
|
<ComboboxEmpty class="text-muted-foreground">No results</ComboboxEmpty>
|
||||||
|
<ComboboxGroup>
|
||||||
|
<template v-for="member in members" :key="member.member_id">
|
||||||
|
<ComboboxItem :value="member"
|
||||||
|
class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5">
|
||||||
|
{{ member.member_name }}
|
||||||
|
<ComboboxItemIndicator class="absolute left-2 inline-flex items-center">
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
</ComboboxItemIndicator>
|
||||||
|
</ComboboxItem>
|
||||||
|
</template>
|
||||||
|
</ComboboxGroup>
|
||||||
|
</ComboboxList>
|
||||||
|
</Combobox>
|
||||||
|
|
||||||
|
<!-- Status Combobox -->
|
||||||
|
<Combobox v-model="currentStatus">
|
||||||
|
<ComboboxAnchor class="w-[300px]">
|
||||||
|
<ComboboxInput placeholder="Search statuses..." class="w-full pl-9"
|
||||||
|
:display-value="(v) => v ? v.name : ''" />
|
||||||
|
</ComboboxAnchor>
|
||||||
|
<ComboboxList class="w-[300px]">
|
||||||
|
<ComboboxEmpty class="text-muted-foreground">No results</ComboboxEmpty>
|
||||||
|
<ComboboxGroup>
|
||||||
|
<template v-for="status in statuses" :key="status.id">
|
||||||
|
<ComboboxItem :value="status"
|
||||||
|
class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5">
|
||||||
|
{{ status.name }}
|
||||||
|
<ComboboxItemIndicator class="absolute left-2 inline-flex items-center">
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
</ComboboxItemIndicator>
|
||||||
|
</ComboboxItem>
|
||||||
|
</template>
|
||||||
|
</ComboboxGroup>
|
||||||
|
</ComboboxList>
|
||||||
|
</Combobox>
|
||||||
|
|
||||||
|
<!-- rank -->
|
||||||
|
<Combobox v-model="currentRank">
|
||||||
|
<ComboboxAnchor class="w-[300px]">
|
||||||
|
<ComboboxInput placeholder="Search ranks..." class="w-full pl-9"
|
||||||
|
:display-value="(v) => v ? v.short_name : ''" />
|
||||||
|
</ComboboxAnchor>
|
||||||
|
<ComboboxList class="w-[300px]">
|
||||||
|
<ComboboxEmpty class="text-muted-foreground">No results</ComboboxEmpty>
|
||||||
|
<ComboboxGroup>
|
||||||
|
<template v-for="rank in allRanks" :key="rank.id">
|
||||||
|
<ComboboxItem :value="rank"
|
||||||
|
class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative cursor-pointer select-none px-2 py-1.5">
|
||||||
|
{{ rank.short_name }}
|
||||||
|
<ComboboxItemIndicator class="absolute left-2 inline-flex items-center">
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
</ComboboxItemIndicator>
|
||||||
|
</ComboboxItem>
|
||||||
|
</template>
|
||||||
|
</ComboboxGroup>
|
||||||
|
</ComboboxList>
|
||||||
|
</Combobox>
|
||||||
|
<Button :onClick="() => { }">Submit</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -11,22 +11,24 @@ import {
|
|||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuShortcut,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { Member, getMembers } from "@/api/member";
|
import { Member, getMembers } from "@/api/member";
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { Ellipsis } from "lucide-vue-next";
|
import { Ellipsis } from "lucide-vue-next";
|
||||||
import Input from "@/components/ui/input/Input.vue";
|
import Input from "@/components/ui/input/Input.vue";
|
||||||
|
import LoaForm from "@/components/loa/loaForm.vue";
|
||||||
|
|
||||||
const members = ref<Member[]>([]);
|
const members = ref<Member[]>([]);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -46,9 +48,24 @@ const searchedMembers = computed(() => {
|
|||||||
return members.value.filter(member => member.member_name.toLowerCase().includes(searchVal.value.toLowerCase()));
|
return members.value.filter(member => member.member_name.toLowerCase().includes(searchVal.value.toLowerCase()));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// page state systems
|
||||||
|
const showLOADialog = ref(false);
|
||||||
|
const LOAuserId = ref<number | null>(null);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<Dialog v-model:open="showLOADialog" v-on:update:open="showLOADialog = false">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>LOA Menu</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Something something flavor text.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<LoaForm :adminMode="true"></LoaForm>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<!-- table menu -->
|
<!-- table menu -->
|
||||||
<div class="w-4xl mx-auto">
|
<div class="w-4xl mx-auto">
|
||||||
<div class="flex justify-between mb-4">
|
<div class="flex justify-between mb-4">
|
||||||
@@ -72,6 +89,8 @@ const searchedMembers = computed(() => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{{ member.rank }}</TableCell>
|
<TableCell>{{ member.rank }}</TableCell>
|
||||||
<TableCell>{{ member.status }}</TableCell>
|
<TableCell>{{ member.status }}</TableCell>
|
||||||
|
<TableCell>{{ member.status }}</TableCell>
|
||||||
|
<TableCell>{{ member.status }}</TableCell>
|
||||||
<TableCell @click.stop="console.log('hi')" class="text-right">
|
<TableCell @click.stop="console.log('hi')" class="text-right">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger class="cursor-pointer">
|
<DropdownMenuTrigger class="cursor-pointer">
|
||||||
@@ -80,7 +99,7 @@ const searchedMembers = computed(() => {
|
|||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem>Change Rank</DropdownMenuItem>
|
<DropdownMenuItem>Change Rank</DropdownMenuItem>
|
||||||
<DropdownMenuItem>Transfer</DropdownMenuItem>
|
<DropdownMenuItem>Transfer</DropdownMenuItem>
|
||||||
<DropdownMenuItem>LOA</DropdownMenuItem>
|
<DropdownMenuItem @click="showLOADialog = true">LOA</DropdownMenuItem>
|
||||||
<DropdownMenuItem :variant="'destructive'">Retire</DropdownMenuItem>
|
<DropdownMenuItem :variant="'destructive'">Retire</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -1,18 +1,35 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import ManageApplications from '@/pages/ManageApplications.vue'
|
|
||||||
import Application from '@/pages/Application.vue'
|
|
||||||
import RankChange from '@/pages/RankChange.vue'
|
|
||||||
import MemberList from '@/pages/memberList.vue'
|
|
||||||
import LOA from '@/pages/LOA.vue'
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/applications', component: ManageApplications },
|
{ path: '/applications', component: () => import('@/pages/ManageApplications.vue') },
|
||||||
{ path: '/applications/:id', component: Application },
|
{ path: '/applications/:id', component: () => import('@/pages/Application.vue') },
|
||||||
{ path: '/changeRank', component: RankChange },
|
{ path: '/rankChange', component: () => import('@/pages/RankChange.vue') },
|
||||||
{ path: '/members', component: MemberList},
|
{ path: '/members', component: () => import('@/pages/memberList.vue') },
|
||||||
{ path: '/loa', component: LOA}
|
{ path: '/loa', component: () => import('@/pages/SubmitLOA.vue') },
|
||||||
|
{ path: '/transfer', component: () => import('@/pages/Transfer.vue') },
|
||||||
|
{
|
||||||
|
path: '/administration',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'applications',
|
||||||
|
component: () => import('@/pages/ManageApplications.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'applications/:id',
|
||||||
|
component: () => import('@/pages/Application.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'transfer-requests',
|
||||||
|
component: () => import('@/pages/RankChange.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'loa',
|
||||||
|
component: () => import('@/pages/ManageLOA.vue')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
10
ui/src/stores/user.ts
Normal file
10
ui/src/stores/user.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export const useUserStore = defineStore('user', () => {
|
||||||
|
const user = ref(null)
|
||||||
|
const roles = ref<string[]>([])
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => user.value !== null)
|
||||||
|
return { user, isLoggedIn, roles }
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user