Compare commits
15 Commits
e025d0f5cc
...
v0.0.1-alp
| Author | SHA1 | Date | |
|---|---|---|---|
| 82eaa23da7 | |||
| b18d1ced6d | |||
| 69c5cf87fd | |||
| 1fadf0ad25 | |||
| beae6eb648 | |||
| 82ab735982 | |||
| dbd56c1b50 | |||
| 037a473ab7 | |||
| 32998d417f | |||
| ddcb7e76a3 | |||
| 191cb2b698 | |||
| 2021141967 | |||
| 751c8f21ab | |||
| 85073c19d2 | |||
| 6b8d7b53d0 |
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
|
||||
"$schema": "https://unpkg.com/@changesets/config/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
|
||||
5
.changeset/neat-years-unite.md
Normal file
5
.changeset/neat-years-unite.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"lst_v3": patch
|
||||
---
|
||||
|
||||
build stuff
|
||||
11
.changeset/pre.json
Normal file
11
.changeset/pre.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"mode": "pre",
|
||||
"tag": "alpha",
|
||||
"initialVersions": {
|
||||
"lst_v3": "1.0.1"
|
||||
},
|
||||
"changesets": [
|
||||
"neat-years-unite",
|
||||
"soft-onions-appear"
|
||||
]
|
||||
}
|
||||
5
.changeset/soft-onions-appear.md
Normal file
5
.changeset/soft-onions-appear.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"lst_v3": patch
|
||||
---
|
||||
|
||||
external url added for docker
|
||||
@@ -6,4 +6,7 @@ Dockerfile
|
||||
docker-compose.yml
|
||||
npm-debug.log
|
||||
builds
|
||||
testFiles
|
||||
testFiles
|
||||
nssm.exe
|
||||
postgresql-17.9-2-windows-x64.exe
|
||||
VSCodeUserSetup-x64-1.112.0.msi
|
||||
31
.gitea/workflows/docker-build.yml
Normal file
31
.gitea/workflows/docker-build.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Build and Push LST Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout (local)
|
||||
run: |
|
||||
git clone https://git.tuffraid.net/cowch/lst_v3.git .
|
||||
git checkout ${{ gitea.sha }}
|
||||
|
||||
- name: Login to registry
|
||||
run: echo "${{ secrets.PASSWORD }}" | docker login git.tuffraid.net -u "cowch" --password-stdin
|
||||
|
||||
- name: Build image
|
||||
run: |
|
||||
docker build \
|
||||
-t git.tuffraid.net/cowch/lst_v3:latest \
|
||||
-t git.tuffraid.net/cowch/lst_v3:${{ gitea.sha }} \
|
||||
.
|
||||
|
||||
- name: Push
|
||||
run: |
|
||||
docker push git.tuffraid.net/cowch/lst_v3:latest
|
||||
docker push git.tuffraid.net/cowch/lst_v3:${{ gitea.sha }}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -6,6 +6,10 @@ builds
|
||||
temp
|
||||
.scriptCreds
|
||||
node-v24.14.0-x64.msi
|
||||
postgresql-17.9-2-windows-x64.exe
|
||||
VSCodeUserSetup-x64-1.112.0.exe
|
||||
nssm.exe
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -3,6 +3,8 @@
|
||||
"workbench.colorTheme": "Default Dark+",
|
||||
"terminal.integrated.env.windows": {},
|
||||
"editor.formatOnSave": true,
|
||||
"typescript.preferences.importModuleSpecifier": "relative",
|
||||
"javascript.preferences.importModuleSpecifier": "relative",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.organizeImports.biome": "explicit"
|
||||
@@ -65,6 +67,7 @@
|
||||
"preseed",
|
||||
"prodlabels",
|
||||
"prolink",
|
||||
"Skelly",
|
||||
"trycatch"
|
||||
],
|
||||
"gitea.token": "8456def90e1c651a761a8711763d6ef225d6b2db",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# lst_v3
|
||||
|
||||
## 1.0.2-alpha.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- build stuff
|
||||
- external url added for docker
|
||||
|
||||
## 1.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -9,10 +9,13 @@ WORKDIR /app
|
||||
# Copy package files
|
||||
COPY . .
|
||||
|
||||
# Install production dependencies only
|
||||
# build backend
|
||||
RUN npm ci
|
||||
RUN npm run build:docker
|
||||
|
||||
RUN npm run build
|
||||
# build frontend
|
||||
RUN npm --prefix frontend ci
|
||||
RUN npm --prefix frontend run build
|
||||
|
||||
###########
|
||||
# Stage 2 #
|
||||
@@ -33,6 +36,9 @@ RUN npm ci --omit=dev
|
||||
|
||||
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/frontend/dist ./frontend/dist
|
||||
|
||||
# TODO add in drizzle migrates
|
||||
|
||||
ENV RUNNING_IN_DOCKER=true
|
||||
EXPOSE 3000
|
||||
|
||||
29
backend/db/schema/notifications.schema.ts
Normal file
29
backend/db/schema/notifications.schema.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
boolean,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
||||
import type z from "zod";
|
||||
|
||||
export const notifications = pgTable(
|
||||
"notifications",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description").notNull(),
|
||||
active: boolean("active").default(false),
|
||||
interval: text("interval").default("5"),
|
||||
options: jsonb("options").default([]),
|
||||
},
|
||||
(table) => [uniqueIndex("notify_name").on(table.name)],
|
||||
);
|
||||
|
||||
export const notificationSchema = createSelectSchema(notifications);
|
||||
export const newNotificationSchema = createInsertSchema(notifications);
|
||||
|
||||
export type Notification = z.infer<typeof notificationSchema>;
|
||||
export type NewNotification = z.infer<typeof newNotificationSchema>;
|
||||
30
backend/db/schema/notifications.sub.schema.ts
Normal file
30
backend/db/schema/notifications.sub.schema.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { pgTable, text, unique, uuid } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
||||
import type z from "zod";
|
||||
import { user } from "./auth.schema.js";
|
||||
import { notifications } from "./notifications.schema.js";
|
||||
|
||||
export const notificationSub = pgTable(
|
||||
"notification_sub",
|
||||
{
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
notificationId: uuid("notification_id")
|
||||
.notNull()
|
||||
.references(() => notifications.id, { onDelete: "cascade" }),
|
||||
emails: text("emails").array().default([]),
|
||||
},
|
||||
(table) => ({
|
||||
userNotificationUnique: unique(
|
||||
"notification_sub_user_notification_unique",
|
||||
).on(table.userId, table.notificationId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const notificationSubSchema = createSelectSchema(notificationSub);
|
||||
export const newNotificationSubSchema = createInsertSchema(notificationSub);
|
||||
|
||||
export type NotificationSub = z.infer<typeof notificationSubSchema>;
|
||||
export type NewNotificationSub = z.infer<typeof newNotificationSubSchema>;
|
||||
6
backend/db/schema/printerLogs.schema.ts
Normal file
6
backend/db/schema/printerLogs.schema.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { integer, pgTable, text } from "drizzle-orm/pg-core";
|
||||
|
||||
export const opendockApt = pgTable("printer_log", {
|
||||
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||
name: text("name").notNull(),
|
||||
});
|
||||
52
backend/middleware/auth.requiredPerms.middleware.ts
Normal file
52
backend/middleware/auth.requiredPerms.middleware.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { auth } from "../utils/auth.utils.js";
|
||||
|
||||
type PermissionMap = Record<string, string[]>;
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
authz?: {
|
||||
success: boolean;
|
||||
permissions: PermissionMap;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRoles(roles: unknown): string {
|
||||
if (Array.isArray(roles)) return roles.join(",");
|
||||
if (typeof roles === "string") return roles;
|
||||
return "";
|
||||
}
|
||||
|
||||
export function requirePermission(permissions: PermissionMap) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const role = normalizeRoles(req.user?.roles) as any;
|
||||
|
||||
const result = await auth.api.userHasPermission({
|
||||
body: {
|
||||
role,
|
||||
permissions,
|
||||
},
|
||||
});
|
||||
|
||||
req.authz = {
|
||||
success: !!result?.success,
|
||||
permissions,
|
||||
};
|
||||
|
||||
if (!result?.success) {
|
||||
return res.status(403).json({
|
||||
ok: false,
|
||||
message: "You do not have permission to perform this action.",
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
153
backend/notification/notification.controller.ts
Normal file
153
backend/notification/notification.controller.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db/db.controller.js";
|
||||
import { notifications } from "../db/schema/notifications.schema.js";
|
||||
import { notificationSub } from "../db/schema/notifications.sub.schema.js";
|
||||
import { createLogger } from "../logger/logger.controller.js";
|
||||
import { minutesToCron } from "../utils/croner.minConvert.js";
|
||||
import { createCronJob, stopCronJob } from "../utils/croner.utils.js";
|
||||
import { tryCatch } from "../utils/trycatch.utils.js";
|
||||
|
||||
const log = createLogger({ module: "notifications", subModule: "start" });
|
||||
|
||||
export const startNotifications = async () => {
|
||||
// get active notification
|
||||
|
||||
const { data, error } = await tryCatch(
|
||||
db.select().from(notifications).where(eq(notifications.active, true)),
|
||||
);
|
||||
|
||||
if (error) {
|
||||
log.error(
|
||||
{ error: error },
|
||||
"There was an error when getting notifications.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
if (data.length === 0) {
|
||||
log.info(
|
||||
{},
|
||||
"There are know currently active notifications to start up.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// get the subs and see if we have any subs currently so we can fire up the notification
|
||||
const { data: sub, error: subError } = await tryCatch(
|
||||
db.select().from(notificationSub),
|
||||
);
|
||||
|
||||
if (subError) {
|
||||
log.error(
|
||||
{ error: error },
|
||||
"There was an error when getting subscriptions.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub.length === 0) {
|
||||
log.info({}, "There are know currently active subscriptions.");
|
||||
return;
|
||||
}
|
||||
|
||||
const emailString = [
|
||||
...new Set(
|
||||
sub.flatMap((e) =>
|
||||
e.emails?.map((email) => email.trim().toLowerCase()),
|
||||
),
|
||||
),
|
||||
].join(";");
|
||||
|
||||
for (const n of data) {
|
||||
createCronJob(
|
||||
n.name,
|
||||
minutesToCron(parseInt(n.interval ?? "15", 10)),
|
||||
async () => {
|
||||
try {
|
||||
const { default: runFun } = await import(
|
||||
`./notification.${n.name.trim()}.js`
|
||||
);
|
||||
await runFun(n, emailString);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
{ error: error },
|
||||
"There was an error starting the notification",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const modifiedNotification = async (id: string) => {
|
||||
// when a notifications subscribed to, updated, deleted we want to get the info and rerun the startup on the single notification.
|
||||
const { data, error } = await tryCatch(
|
||||
db.select().from(notifications).where(eq(notifications.id, id)),
|
||||
);
|
||||
|
||||
if (error) {
|
||||
log.error(
|
||||
{ error: error },
|
||||
"There was an error when getting notifications.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
if (!data[0]?.active) {
|
||||
stopCronJob(data[0]?.name ?? "");
|
||||
return;
|
||||
}
|
||||
|
||||
// get the subs for the specific id as we only want to up the modified one
|
||||
const { data: sub, error: subError } = await tryCatch(
|
||||
db
|
||||
.select()
|
||||
.from(notificationSub)
|
||||
.where(eq(notificationSub.notificationId, id)),
|
||||
);
|
||||
|
||||
if (subError) {
|
||||
log.error(
|
||||
{ error: error },
|
||||
"There was an error when getting subscriptions.",
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub.length === 0) {
|
||||
log.info({}, "There are know currently active subscriptions.");
|
||||
stopCronJob(data[0]?.name ?? "");
|
||||
return;
|
||||
}
|
||||
|
||||
const emailString = [
|
||||
...new Set(
|
||||
sub.flatMap((e) =>
|
||||
e.emails?.map((email) => email.trim().toLowerCase()),
|
||||
),
|
||||
),
|
||||
].join(";");
|
||||
|
||||
createCronJob(
|
||||
data[0].name,
|
||||
minutesToCron(parseInt(data[0].interval ?? "15", 10)),
|
||||
async () => {
|
||||
try {
|
||||
const { default: runFun } = await import(
|
||||
`./notification.${data[0]?.name.trim()}.js`
|
||||
);
|
||||
await runFun(data[0], emailString);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
{ error: error },
|
||||
"There was an error starting the notification",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
10
backend/notification/notification.reprintLabels.ts
Normal file
10
backend/notification/notification.reprintLabels.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
const reprint = (data: any, emails: string) => {
|
||||
// TODO: do the actual logic for the notification.
|
||||
console.log(data);
|
||||
console.log(emails);
|
||||
|
||||
// TODO send the error to systemAdmin users so they do not always need to be on the notifications.
|
||||
// these errors are defined per notification.
|
||||
};
|
||||
|
||||
export default reprint;
|
||||
55
backend/notification/notification.route.ts
Normal file
55
backend/notification/notification.route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { type Response, Router } from "express";
|
||||
|
||||
import { db } from "../db/db.controller.js";
|
||||
import { notifications } from "../db/schema/notifications.schema.js";
|
||||
import { auth } from "../utils/auth.utils.js";
|
||||
import { apiReturn } from "../utils/returnHelper.utils.js";
|
||||
import { tryCatch } from "../utils/trycatch.utils.js";
|
||||
|
||||
const r = Router();
|
||||
|
||||
r.get("/", async (req, res: Response) => {
|
||||
const hasPermissions = await auth.api.userHasPermission({
|
||||
body: {
|
||||
//userId: req?.user?.id,
|
||||
role: req.user?.roles as any,
|
||||
permissions: {
|
||||
notifications: ["readAll"], // This must match the structure in your access control
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { data: nName, error: nError } = await tryCatch(
|
||||
db
|
||||
.select()
|
||||
.from(notifications)
|
||||
.where(
|
||||
!hasPermissions.success ? eq(notifications.active, true) : undefined,
|
||||
)
|
||||
.orderBy(notifications.name),
|
||||
);
|
||||
|
||||
if (nError) {
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error",
|
||||
module: "notification",
|
||||
subModule: "get",
|
||||
message: `There was an error getting the notifications `,
|
||||
data: [nError],
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return apiReturn(res, {
|
||||
success: true,
|
||||
level: "info",
|
||||
module: "notification",
|
||||
subModule: "get",
|
||||
message: `All current notifications`,
|
||||
data: nName ?? [],
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
export default r;
|
||||
20
backend/notification/notification.routes.ts
Normal file
20
backend/notification/notification.routes.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Express } from "express";
|
||||
import { requireAuth } from "../middleware/auth.middleware.js";
|
||||
import getNotifications from "./notification.route.js";
|
||||
import updateNote from "./notification.update.route.js";
|
||||
import deleteSub from "./notificationSub.delete.route.js";
|
||||
import subs from "./notificationSub.get.route.js";
|
||||
import newSub from "./notificationSub.post.route.js";
|
||||
import updateSub from "./notificationSub.update.route.js";
|
||||
|
||||
export const setupNotificationRoutes = (baseUrl: string, app: Express) => {
|
||||
//stats will be like this as we dont need to change this
|
||||
app.use(`${baseUrl}/api/notification`, requireAuth, getNotifications);
|
||||
app.use(`${baseUrl}/api/notification`, requireAuth, updateNote);
|
||||
app.use(`${baseUrl}/api/notification/sub`, requireAuth, subs);
|
||||
app.use(`${baseUrl}/api/notification/sub`, requireAuth, newSub);
|
||||
app.use(`${baseUrl}/api/notification/sub`, requireAuth, updateSub);
|
||||
app.use(`${baseUrl}/api/notification/sub`, requireAuth, deleteSub);
|
||||
|
||||
// all other system should be under /api/system/*
|
||||
};
|
||||
81
backend/notification/notification.update.route.ts
Normal file
81
backend/notification/notification.update.route.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { type Response, Router } from "express";
|
||||
import z from "zod";
|
||||
import { db } from "../db/db.controller.js";
|
||||
import { notifications } from "../db/schema/notifications.schema.js";
|
||||
import { requirePermission } from "../middleware/auth.requiredPerms.middleware.js";
|
||||
import { apiReturn } from "../utils/returnHelper.utils.js";
|
||||
import { tryCatch } from "../utils/trycatch.utils.js";
|
||||
import { modifiedNotification } from "./notification.controller.js";
|
||||
|
||||
const r = Router();
|
||||
|
||||
const updateNote = z.object({
|
||||
description: z.string().optional(),
|
||||
active: z.boolean().optional(),
|
||||
interval: z.string().optional(),
|
||||
options: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
});
|
||||
|
||||
r.patch(
|
||||
"/:id",
|
||||
requirePermission({ notifications: ["update"] }),
|
||||
async (req, res: Response) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const validated = updateNote.parse(req.body);
|
||||
|
||||
const { data: nName, error: nError } = await tryCatch(
|
||||
db
|
||||
.update(notifications)
|
||||
.set(validated)
|
||||
.where(eq(notifications.id, id as string))
|
||||
.returning(),
|
||||
);
|
||||
|
||||
await modifiedNotification(id as string);
|
||||
|
||||
if (nError) {
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error",
|
||||
module: "notification",
|
||||
subModule: "update",
|
||||
message: `There was an error getting the notifications `,
|
||||
data: [nError],
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return apiReturn(res, {
|
||||
success: true,
|
||||
level: "info",
|
||||
module: "notification",
|
||||
subModule: "update",
|
||||
message: `Notification was updated`,
|
||||
data: nName ?? [],
|
||||
status: 200,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
const flattened = z.flattenError(err);
|
||||
// return res.status(400).json({
|
||||
// error: "Validation failed",
|
||||
// details: flattened,
|
||||
// });
|
||||
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error", //connect.success ? "info" : "error",
|
||||
module: "routes",
|
||||
subModule: "notification",
|
||||
message: "Validation failed",
|
||||
data: [flattened.fieldErrors],
|
||||
status: 400, //connect.success ? 200 : 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
export default r;
|
||||
76
backend/notification/notificationSub.delete.route.ts
Normal file
76
backend/notification/notificationSub.delete.route.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { type Response, Router } from "express";
|
||||
import z from "zod";
|
||||
import { db } from "../db/db.controller.js";
|
||||
import { notificationSub } from "../db/schema/notifications.sub.schema.js";
|
||||
import { apiReturn } from "../utils/returnHelper.utils.js";
|
||||
import { tryCatch } from "../utils/trycatch.utils.js";
|
||||
import { modifiedNotification } from "./notification.controller.js";
|
||||
|
||||
const newSubscribe = z.object({
|
||||
emails: z.email().array().describe("An array of emails"),
|
||||
userId: z.string().describe("User id."),
|
||||
notificationId: z.string().describe("Notification id"),
|
||||
});
|
||||
|
||||
const r = Router();
|
||||
|
||||
r.delete("/", async (req, res: Response) => {
|
||||
try {
|
||||
const validated = newSubscribe.parse(req.body);
|
||||
const { data, error } = await tryCatch(
|
||||
db
|
||||
.delete(notificationSub)
|
||||
.where(
|
||||
and(
|
||||
eq(notificationSub.userId, validated.userId),
|
||||
eq(notificationSub.notificationId, validated.notificationId),
|
||||
),
|
||||
)
|
||||
.returning(),
|
||||
);
|
||||
|
||||
await modifiedNotification(validated.notificationId);
|
||||
|
||||
if (error) {
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error",
|
||||
module: "notification",
|
||||
subModule: "post",
|
||||
message: `There was an error deleting the subscription `,
|
||||
data: [error],
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return apiReturn(res, {
|
||||
success: true,
|
||||
level: "info",
|
||||
module: "notification",
|
||||
subModule: "post",
|
||||
message: `Subscription deleted`,
|
||||
data: data ?? [],
|
||||
status: 200,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
const flattened = z.flattenError(err);
|
||||
// return res.status(400).json({
|
||||
// error: "Validation failed",
|
||||
// details: flattened,
|
||||
// });
|
||||
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error", //connect.success ? "info" : "error",
|
||||
module: "routes",
|
||||
subModule: "notification",
|
||||
message: "Validation failed",
|
||||
data: [flattened.fieldErrors],
|
||||
status: 400, //connect.success ? 200 : 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
export default r;
|
||||
57
backend/notification/notificationSub.get.route.ts
Normal file
57
backend/notification/notificationSub.get.route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { type Response, Router } from "express";
|
||||
import { db } from "../db/db.controller.js";
|
||||
import { notificationSub } from "../db/schema/notifications.sub.schema.js";
|
||||
import { auth } from "../utils/auth.utils.js";
|
||||
import { apiReturn } from "../utils/returnHelper.utils.js";
|
||||
import { tryCatch } from "../utils/trycatch.utils.js";
|
||||
|
||||
const r = Router();
|
||||
|
||||
r.get("/", async (req, res: Response) => {
|
||||
const { userId } = req.query;
|
||||
|
||||
const hasPermissions = await auth.api.userHasPermission({
|
||||
body: {
|
||||
//userId: req?.user?.id,
|
||||
role: req.user?.roles as any,
|
||||
permissions: {
|
||||
notifications: ["readAll"], // This must match the structure in your access control
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { data, error } = await tryCatch(
|
||||
db
|
||||
.select()
|
||||
.from(notificationSub)
|
||||
.where(
|
||||
userId || !hasPermissions.success
|
||||
? eq(notificationSub.userId, `${req?.user?.id ?? ""}`)
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error",
|
||||
module: "notification",
|
||||
subModule: "post",
|
||||
message: `There was an error getting subscriptions `,
|
||||
data: [error],
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return apiReturn(res, {
|
||||
success: true,
|
||||
level: "info",
|
||||
module: "notification",
|
||||
subModule: "post",
|
||||
message: `Subscriptions`,
|
||||
data: data ?? [],
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
export default r;
|
||||
75
backend/notification/notificationSub.post.route.ts
Normal file
75
backend/notification/notificationSub.post.route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { type Response, Router } from "express";
|
||||
import z from "zod";
|
||||
import { db } from "../db/db.controller.js";
|
||||
import { notificationSub } from "../db/schema/notifications.sub.schema.js";
|
||||
import { apiReturn } from "../utils/returnHelper.utils.js";
|
||||
import { tryCatch } from "../utils/trycatch.utils.js";
|
||||
import { modifiedNotification } from "./notification.controller.js";
|
||||
|
||||
const newSubscribe = z.object({
|
||||
emails: z
|
||||
.email()
|
||||
.array()
|
||||
|
||||
.describe("An array of emails"),
|
||||
userId: z.string().describe("User id."),
|
||||
notificationId: z
|
||||
.string()
|
||||
|
||||
.describe("Notification id"),
|
||||
});
|
||||
|
||||
const r = Router();
|
||||
|
||||
r.post("/", async (req, res: Response) => {
|
||||
try {
|
||||
const validated = newSubscribe.parse(req.body);
|
||||
|
||||
const { data, error } = await tryCatch(
|
||||
db.insert(notificationSub).values(validated).returning(),
|
||||
);
|
||||
|
||||
await modifiedNotification(validated.notificationId);
|
||||
|
||||
if (error) {
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error",
|
||||
module: "notification",
|
||||
subModule: "post",
|
||||
message: `There was an error getting the notifications `,
|
||||
data: [error],
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return apiReturn(res, {
|
||||
success: true,
|
||||
level: "info",
|
||||
module: "notification",
|
||||
subModule: "post",
|
||||
message: `Subscribed to notification`,
|
||||
data: data ?? [],
|
||||
status: 200,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
const flattened = z.flattenError(err);
|
||||
// return res.status(400).json({
|
||||
// error: "Validation failed",
|
||||
// details: flattened,
|
||||
// });
|
||||
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error", //connect.success ? "info" : "error",
|
||||
module: "routes",
|
||||
subModule: "notification",
|
||||
message: "Validation failed",
|
||||
data: [flattened.fieldErrors],
|
||||
status: 400, //connect.success ? 200 : 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
export default r;
|
||||
84
backend/notification/notificationSub.update.route.ts
Normal file
84
backend/notification/notificationSub.update.route.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { type Response, Router } from "express";
|
||||
import z from "zod";
|
||||
import { db } from "../db/db.controller.js";
|
||||
import { notificationSub } from "../db/schema/notifications.sub.schema.js";
|
||||
import { apiReturn } from "../utils/returnHelper.utils.js";
|
||||
import { tryCatch } from "../utils/trycatch.utils.js";
|
||||
import { modifiedNotification } from "./notification.controller.js";
|
||||
|
||||
const newSubscribe = z.object({
|
||||
emails: z.email().array().describe("An array of emails"),
|
||||
userId: z.string().describe("User id."),
|
||||
notificationId: z.string().describe("Notification id"),
|
||||
});
|
||||
|
||||
const r = Router();
|
||||
|
||||
r.patch("/", async (req, res: Response) => {
|
||||
try {
|
||||
const validated = newSubscribe.parse(req.body);
|
||||
|
||||
const emails = validated.emails
|
||||
.map((e) => e.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
const uniqueEmails = [...new Set(emails)];
|
||||
|
||||
const { data, error } = await tryCatch(
|
||||
db
|
||||
.update(notificationSub)
|
||||
.set({ emails: uniqueEmails })
|
||||
.where(
|
||||
and(
|
||||
eq(notificationSub.userId, validated.userId),
|
||||
eq(notificationSub.notificationId, validated.notificationId),
|
||||
),
|
||||
)
|
||||
.returning(),
|
||||
);
|
||||
|
||||
await modifiedNotification(validated.notificationId);
|
||||
|
||||
if (error) {
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error",
|
||||
module: "notification",
|
||||
subModule: "update",
|
||||
message: `There was an error updating the notifications `,
|
||||
data: [error],
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return apiReturn(res, {
|
||||
success: true,
|
||||
level: "info",
|
||||
module: "notification",
|
||||
subModule: "update",
|
||||
message: `Subscription updated`,
|
||||
data: data ?? [],
|
||||
status: 200,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
const flattened = z.flattenError(err);
|
||||
// return res.status(400).json({
|
||||
// error: "Validation failed",
|
||||
// details: flattened,
|
||||
// });
|
||||
|
||||
return apiReturn(res, {
|
||||
success: false,
|
||||
level: "error", //connect.success ? "info" : "error",
|
||||
module: "routes",
|
||||
subModule: "notification",
|
||||
message: "Validation failed",
|
||||
data: [flattened.fieldErrors],
|
||||
status: 400, //connect.success ? 200 : 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
export default r;
|
||||
50
backend/notification/notifications.master.ts
Normal file
50
backend/notification/notifications.master.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "../db/db.controller.js";
|
||||
import {
|
||||
type NewNotification,
|
||||
notifications,
|
||||
} from "../db/schema/notifications.schema.js";
|
||||
import { createLogger } from "../logger/logger.controller.js";
|
||||
import { tryCatch } from "../utils/trycatch.utils.js";
|
||||
|
||||
const note: NewNotification[] = [
|
||||
{
|
||||
name: "reprintLabels",
|
||||
description:
|
||||
"Monitors the labels that are printed and returns a there data, if one falls withing the time frame.",
|
||||
active: false,
|
||||
interval: "10",
|
||||
options: [{ prodID: 1 }],
|
||||
},
|
||||
];
|
||||
|
||||
export const createNotifications = async () => {
|
||||
const log = createLogger({ module: "notifications", subModule: "create" });
|
||||
const { data, error } = await tryCatch(
|
||||
db
|
||||
.insert(notifications)
|
||||
.values(note)
|
||||
.onConflictDoUpdate({
|
||||
target: notifications.name,
|
||||
set: {
|
||||
description: sql`excluded.description`,
|
||||
},
|
||||
// where: sql`
|
||||
// settings.seed_version IS NULL
|
||||
// OR settings.seed_version < excluded.seed_version
|
||||
// `,
|
||||
})
|
||||
.returning(),
|
||||
);
|
||||
|
||||
if (error) {
|
||||
log.error(
|
||||
{ error: error },
|
||||
"There was an error when adding or updating the notifications.",
|
||||
);
|
||||
}
|
||||
|
||||
if (data) {
|
||||
log.info({}, "All notifications were added/updated");
|
||||
}
|
||||
};
|
||||
36
backend/ocp/ocp.printer.listener.ts
Normal file
36
backend/ocp/ocp.printer.listener.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* the route that listens for the printers post.
|
||||
*
|
||||
* and http-post alert should be setup on each printer pointing to at min you will want to make the alert for
|
||||
* pause printer, you can have all on here as it will also monitor and do things on all messages
|
||||
*
|
||||
* http://{serverIP}:2222/lst/api/ocp/printer/listener/{printerName}
|
||||
*
|
||||
* the messages will be sent over to the db for logging as well as specific ones will do something
|
||||
*
|
||||
* pause will validate if can print
|
||||
* close head will repause the printer so it wont print a label
|
||||
* power up will just repause the printer so it wont print a label
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import { apiReturn } from "../utils/returnHelper.utils.js";
|
||||
|
||||
const r = Router();
|
||||
|
||||
r.post("/printer/listener/:printer", async (req, res) => {
|
||||
const { printer: printerName } = req.params;
|
||||
console.log(req.body);
|
||||
|
||||
return apiReturn(res, {
|
||||
success: true,
|
||||
level: "info",
|
||||
module: "ocp",
|
||||
subModule: "printing",
|
||||
message: `${printerName} just passed over a message`,
|
||||
data: req.body ?? [],
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
|
||||
export default r;
|
||||
19
backend/ocp/ocp.printer.manage.ts
Normal file
19
backend/ocp/ocp.printer.manage.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* this will do a prod sync, update or add alerts to the printer, validate the next pm intervale as well as head replacement.
|
||||
*
|
||||
* if a printer is upcoming on a pm or head replacement send to the plant to address.
|
||||
*
|
||||
* a trigger on the printer table will have the ability to run this as well
|
||||
*
|
||||
* heat beats on all assigned printers
|
||||
*
|
||||
* printer status will live here this will be how we manage all the levels of status like 3 paused, 1 printing, 8 error, 10 power up, etc...
|
||||
*/
|
||||
|
||||
export const printerManager = async () => {};
|
||||
|
||||
export const printerHeartBeat = async () => {
|
||||
// heat heats will be defaulted to 60 seconds no reason to allow anything else
|
||||
};
|
||||
|
||||
//export const printerStatus = async (statusNr: number, printerId: number) => {};
|
||||
22
backend/ocp/ocp.routes.ts
Normal file
22
backend/ocp/ocp.routes.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { type Express, Router } from "express";
|
||||
import { requireAuth } from "../middleware/auth.middleware.js";
|
||||
import { featureCheck } from "../middleware/featureActive.middleware.js";
|
||||
import listener from "./ocp.printer.listener.js";
|
||||
|
||||
export const setupOCPRoutes = (baseUrl: string, app: Express) => {
|
||||
//setup all the routes
|
||||
const router = Router();
|
||||
|
||||
// is the feature even on?
|
||||
router.use(featureCheck("ocp"));
|
||||
|
||||
// non auth routes up here
|
||||
router.use(listener);
|
||||
|
||||
// auth routes below here
|
||||
router.use(requireAuth);
|
||||
|
||||
//router.use("");
|
||||
|
||||
app.use(`${baseUrl}/api/ocp`, router);
|
||||
};
|
||||
188
backend/rfid/daytonConfig copy.json
Normal file
188
backend/rfid/daytonConfig copy.json
Normal file
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"GPIO-LED": {
|
||||
"GPODefaults": {
|
||||
"1": "HIGH",
|
||||
"2": "HIGH",
|
||||
"3": "HIGH",
|
||||
"4": "HIGH"
|
||||
},
|
||||
"LEDDefaults": {
|
||||
"3": "GREEN"
|
||||
},
|
||||
"TAG_READ": [
|
||||
{
|
||||
"pin": 1,
|
||||
"state": "HIGH",
|
||||
"type": "GPO"
|
||||
}
|
||||
]
|
||||
},
|
||||
"READER-GATEWAY": {
|
||||
"batching": [
|
||||
{
|
||||
"maxPayloadSizePerReport": 256000,
|
||||
"reportingInterval": 2000
|
||||
}
|
||||
],
|
||||
"endpointConfig": {
|
||||
"data": {
|
||||
"event": {
|
||||
"connections": [
|
||||
{
|
||||
"additionalOptions": {
|
||||
"batching": {
|
||||
"maxPayloadSizePerReport": 256000,
|
||||
"reportingInterval": 2000
|
||||
},
|
||||
"retention": {
|
||||
"maxEventRetentionTimeInMin": 500,
|
||||
"maxNumEvents": 150000,
|
||||
"throttle": 100
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"name": "LST",
|
||||
"options": {
|
||||
"URL": "https://usday1prod.alpla.net/lst/old/api/rfid/taginfo/line3.4",
|
||||
"security": {
|
||||
"CACertificateFileLocation": "",
|
||||
"authenticationOptions": {
|
||||
"privateKeyFileLocation": "/readerconfig/ssl/server.key",
|
||||
"publicKeyFileLocation": "/readerconfig/ssl/server.crt"
|
||||
},
|
||||
"authenticationType": "NONE",
|
||||
"verifyHost": false,
|
||||
"verifyPeer": false
|
||||
}
|
||||
},
|
||||
"type": "httpPost"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"managementEventConfig": {
|
||||
"errors": {
|
||||
"antenna": false,
|
||||
"cpu": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 90
|
||||
},
|
||||
"database": true,
|
||||
"flash": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 90
|
||||
},
|
||||
"ntp": true,
|
||||
"radio": true,
|
||||
"radio_control": true,
|
||||
"ram": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 90
|
||||
},
|
||||
"reader_gateway": true,
|
||||
"userApp": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 120
|
||||
}
|
||||
},
|
||||
"gpiEvents": true,
|
||||
"gpoEvents": true,
|
||||
"heartbeat": {
|
||||
"fields": {
|
||||
"radio_control": [
|
||||
"ANTENNAS",
|
||||
"RADIO_ACTIVITY",
|
||||
"RADIO_CONNECTION",
|
||||
"CPU",
|
||||
"RAM",
|
||||
"UPTIME",
|
||||
"NUM_ERRORS",
|
||||
"NUM_WARNINGS",
|
||||
"NUM_TAG_READS",
|
||||
"NUM_TAG_READS_PER_ANTENNA",
|
||||
"NUM_DATA_MESSAGES_TXED",
|
||||
"NUM_RADIO_PACKETS_RXED"
|
||||
],
|
||||
"reader_gateway": [
|
||||
"NUM_DATA_MESSAGES_RXED",
|
||||
"NUM_MANAGEMENT_EVENTS_TXED",
|
||||
"NUM_DATA_MESSAGES_TXED",
|
||||
"NUM_DATA_MESSAGES_RETAINED",
|
||||
"NUM_DATA_MESSAGES_DROPPED",
|
||||
"CPU",
|
||||
"RAM",
|
||||
"UPTIME",
|
||||
"NUM_ERRORS",
|
||||
"NUM_WARNINGS",
|
||||
"INTERFACE_CONNECTION_STATUS",
|
||||
"NOLOCKQ_DEPTH"
|
||||
],
|
||||
"system": [
|
||||
"CPU",
|
||||
"FLASH",
|
||||
"NTP",
|
||||
"RAM",
|
||||
"SYSTEMTIME",
|
||||
"TEMPERATURE",
|
||||
"UPTIME",
|
||||
"GPO",
|
||||
"GPI",
|
||||
"POWER_NEGOTIATION",
|
||||
"POWER_SOURCE",
|
||||
"MAC_ADDRESS",
|
||||
"HOSTNAME"
|
||||
],
|
||||
"userDefined": null,
|
||||
"userapps": [
|
||||
"STATUS",
|
||||
"CPU",
|
||||
"RAM",
|
||||
"UPTIME",
|
||||
"NUM_DATA_MESSAGES_RXED",
|
||||
"NUM_DATA_MESSAGES_TXED",
|
||||
"INCOMING_DATA_BUFFER_PERCENTAGE_REMAINING",
|
||||
"OUTGOING_DATA_BUFFER_PERCENTAGE_REMAINING"
|
||||
]
|
||||
},
|
||||
"interval": 60
|
||||
},
|
||||
"userappEvents": true,
|
||||
"warnings": {
|
||||
"cpu": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 80
|
||||
},
|
||||
"database": true,
|
||||
"flash": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 80
|
||||
},
|
||||
"ntp": true,
|
||||
"radio_api": true,
|
||||
"radio_control": true,
|
||||
"ram": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 80
|
||||
},
|
||||
"reader_gateway": true,
|
||||
"temperature": {
|
||||
"ambient": 75,
|
||||
"pa": 105
|
||||
},
|
||||
"userApp": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 60
|
||||
}
|
||||
}
|
||||
},
|
||||
"retention": [
|
||||
{
|
||||
"maxEventRetentionTimeInMin": 500,
|
||||
"maxNumEvents": 150000,
|
||||
"throttle": 100
|
||||
}
|
||||
]
|
||||
},
|
||||
"xml": "<?xml version='1.0'?>\n<Motorola xmlns:Falcon='http://www.motorola.com/RFID/Readers/Config/Falcon' xmlns='http://www.motorola.com/RFID/Readers/Config/Falcon'>\n<Config>\n<AppVersion major='3' minor='28' build='1' maintenance='0'/>\n<CommConfig EnabledStacks='IPV4' DisableRAPktProcessing='1' EnableDHCPv6='1' IPv6StaticIPAddr='fe80::1' IPv6SubnetMask='64' IPv6StaticGateway='::' IPv6DNSIP='fe80::20' DHCP='1' IPAddr='10.44.14.39' Mask='255.255.255.0' Gateway='10.44.14.252' DNS='10.44.9.250' DomainSearch='example.com' HttpRunning='2' TelnetActive='2' FtpActive='2' usbMode='0' WatchdogEnabled='1' AvahiEnabled='1' NetBIOSEnabled='0' RDMPAgentEnabled='1' SerialConTimeout='0' SNTP='0.0.0.0' SNTPHostName='pool.ntp.org' sntpHostDisplayMode='0' llrpClientMode='0' llrpSecureMode='0' llrpSecureModeValidatePeer='0' llrpPort='5084' llrpHostIP='192.168.127.2' allowllrpConnOverride='0' shouldReconnect='1'/>\n<Bluetooth discoverable='0' pairable='0' PincodeEnabled='0' passkey='165CB22DA5BE7BBEFB77709DD0A94B03FB77709DD0A94B03FB77709DD0A94B03FB77709DD0A94B03FB77709DD0A94B03FB77709DD0A94B03FB77709DD0A94B03' startIP='192.168.0.2' endIP='192.168.0.3'/>\n<WirelessConfig essid='' autoconnect='0'/>\n<RegionConfig RFCountry='United States/Canada' RFRegulatory='US FCC 15' RFScanMode='0' LBTEnable='0' ChannelData='FFFFFFFFFFFFFFFF'/>\n<SnmpConfig snmpVersion='1' heartbeat='1'/>\n<SyslogConfig RemoteIp='0.0.0.0' RemotePort='514' LogMinSeverity='7' ApplyFilter='0' MinimumSeverity='7' ProcessFilter='rmserver.elf,llrpserver.elf,snmpextagent.elf,RDMPAgent'/>\n<UserList>\n<User name='admin' PSWD='$6$weLpDwlv$utr0AwgPIae2O4Gln4cQ2IJJblXye412Xqni0V.ahIFKUOCEDGjzZ4ttthhrw7rmmQYsCXKwA9znyqPkAT.IL/'/>\n<User name='rfidadm' PSWD='15491'/>\n</UserList>\n<IPReader name='FX96007AF832 FX9600 RFID Reader' desc='FX96007AF832 Advanced Reader' flags='0' MonoStatic='0' CheckAntenna='1' gpiDebounceTime='0' gpioMapping='0' idleModeTimeOut='0' diagMode='0' extDiagMode='0' contact='Zebra Technologies Corporation' PowerNegotiation='0' PowerNegotiationProtocol='0' allowGuestLogin='1' configureHostName='0'>\n<ReadPoint name='Read Point 1' flags='0' CableLossPerHundredFt='10' CableLength='10'/>\n<ReadPoint name='Read Point 2' flags='0' CableLossPerHundredFt='10' CableLength='10'/>\n<ReadPoint name='Read Point 3' flags='1' CableLossPerHundredFt='10' CableLength='10'/>\n<ReadPoint name='Read Point 4' flags='1' CableLossPerHundredFt='10' CableLength='10'/>\n</IPReader>\n<SerialPortConf Mode='0' Baudrate='115200' Databits='8' Parity='none' Stopbits='1' Flowcontrol='hardware' TagMetaData='0' InventoryControl='0' IsAutostart='0'/>\n<FXConnectConfig FXConnectMode='0' TagMetaData='0' InventoryControl='None' HeartBeatPeriod='0' IsAutostart='0' PreFilterMode='0' PreFilters='None'/>\n<ProfinetConfig virtualDAP='1'/>\n<NodeJSPortConf Portnumber='8001'/>\n</Config>\n<MOTOROLA_LLRP_CONFIG><LLRP_READER_CONFIG />\n</MOTOROLA_LLRP_CONFIG>\n<IOT_CONNECT_CONFIG><OPERATING_MODE />\n</IOT_CONNECT_CONFIG>\n<RadioProfileData><RadioRegisterData Address='0' Data='00'/>\n</RadioProfileData>\n<CustomProfileData ForceEAPMode='0' FIPS_MODE_ENABLED='0' MaxNumberOfTagsBuffered='512'/>\n</Motorola >\n"
|
||||
}
|
||||
206
backend/rfid/daytonConfig.json
Normal file
206
backend/rfid/daytonConfig.json
Normal file
@@ -0,0 +1,206 @@
|
||||
{
|
||||
"GPIO-LED": {
|
||||
"GPODefaults": {
|
||||
"1": "HIGH",
|
||||
"2": "HIGH",
|
||||
"3": "HIGH",
|
||||
"4": "HIGH"
|
||||
},
|
||||
"LEDDefaults": {
|
||||
"3": "GREEN"
|
||||
},
|
||||
"TAG_READ": [
|
||||
{
|
||||
"pin": 1,
|
||||
"state": "HIGH",
|
||||
"type": "GPO"
|
||||
}
|
||||
]
|
||||
},
|
||||
"READER-GATEWAY": {
|
||||
"batching": [
|
||||
{
|
||||
"maxPayloadSizePerReport": 256000,
|
||||
"reportingInterval": 2000
|
||||
}
|
||||
],
|
||||
"endpointConfig": {
|
||||
"data": {
|
||||
"event": {
|
||||
"connections": [
|
||||
{
|
||||
"additionalOptions": {
|
||||
"retention": {
|
||||
"maxEventRetentionTimeInMin": 500,
|
||||
"maxNumEvents": 150000,
|
||||
"throttle": 100
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"name": "lst",
|
||||
"options": {
|
||||
"URL": "http://usday1vms006:3100/api/rfid/taginfo/wrapper1",
|
||||
"security": {
|
||||
"CACertificateFileLocation": "",
|
||||
"authenticationOptions": {},
|
||||
"authenticationType": "NONE",
|
||||
"verifyHost": false,
|
||||
"verifyPeer": false
|
||||
}
|
||||
},
|
||||
"type": "httpPost"
|
||||
},
|
||||
{
|
||||
"additionalOptions": {
|
||||
"retention": {
|
||||
"maxEventRetentionTimeInMin": 500,
|
||||
"maxNumEvents": 150000,
|
||||
"throttle": 100
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"name": "mgt",
|
||||
"options": {
|
||||
"URL": "http://usday1vms006:3100/api/rfid/mgtevents/wrapper1",
|
||||
"security": {
|
||||
"CACertificateFileLocation": "",
|
||||
"authenticationOptions": {},
|
||||
"authenticationType": "NONE",
|
||||
"verifyHost": false,
|
||||
"verifyPeer": false
|
||||
}
|
||||
},
|
||||
"type": "httpPost"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"interfaces": {
|
||||
"tagDataInterface1": "lst",
|
||||
"managementEventsInterface": "mgt"
|
||||
},
|
||||
"managementEventConfig": {
|
||||
"errors": {
|
||||
"antenna": false,
|
||||
"cpu": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 90
|
||||
},
|
||||
"database": true,
|
||||
"flash": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 90
|
||||
},
|
||||
"ntp": true,
|
||||
"radio": true,
|
||||
"radio_control": true,
|
||||
"ram": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 90
|
||||
},
|
||||
"reader_gateway": true,
|
||||
"userApp": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 120
|
||||
}
|
||||
},
|
||||
"gpiEvents": true,
|
||||
"gpoEvents": true,
|
||||
"heartbeat": {
|
||||
"fields": {
|
||||
"radio_control": [
|
||||
"ANTENNAS",
|
||||
"RADIO_ACTIVITY",
|
||||
"RADIO_CONNECTION",
|
||||
"CPU",
|
||||
"RAM",
|
||||
"UPTIME",
|
||||
"NUM_ERRORS",
|
||||
"NUM_WARNINGS",
|
||||
"NUM_TAG_READS",
|
||||
"NUM_TAG_READS_PER_ANTENNA",
|
||||
"NUM_DATA_MESSAGES_TXED",
|
||||
"NUM_RADIO_PACKETS_RXED"
|
||||
],
|
||||
"reader_gateway": [
|
||||
"NUM_DATA_MESSAGES_RXED",
|
||||
"NUM_MANAGEMENT_EVENTS_TXED",
|
||||
"NUM_DATA_MESSAGES_TXED",
|
||||
"NUM_DATA_MESSAGES_RETAINED",
|
||||
"NUM_DATA_MESSAGES_DROPPED",
|
||||
"CPU",
|
||||
"RAM",
|
||||
"UPTIME",
|
||||
"NUM_ERRORS",
|
||||
"NUM_WARNINGS",
|
||||
"INTERFACE_CONNECTION_STATUS",
|
||||
"NOLOCKQ_DEPTH"
|
||||
],
|
||||
"system": [
|
||||
"CPU",
|
||||
"FLASH",
|
||||
"NTP",
|
||||
"RAM",
|
||||
"SYSTEMTIME",
|
||||
"TEMPERATURE",
|
||||
"UPTIME",
|
||||
"GPO",
|
||||
"GPI",
|
||||
"POWER_NEGOTIATION",
|
||||
"POWER_SOURCE",
|
||||
"MAC_ADDRESS",
|
||||
"HOSTNAME"
|
||||
],
|
||||
"userDefined": null,
|
||||
"userapps": [
|
||||
"STATUS",
|
||||
"CPU",
|
||||
"RAM",
|
||||
"UPTIME",
|
||||
"NUM_DATA_MESSAGES_RXED",
|
||||
"NUM_DATA_MESSAGES_TXED",
|
||||
"INCOMING_DATA_BUFFER_PERCENTAGE_REMAINING",
|
||||
"OUTGOING_DATA_BUFFER_PERCENTAGE_REMAINING"
|
||||
]
|
||||
},
|
||||
"interval": 60
|
||||
},
|
||||
"userappEvents": true,
|
||||
"warnings": {
|
||||
"cpu": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 80
|
||||
},
|
||||
"database": true,
|
||||
"flash": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 80
|
||||
},
|
||||
"ntp": true,
|
||||
"radio_api": true,
|
||||
"radio_control": true,
|
||||
"ram": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 80
|
||||
},
|
||||
"reader_gateway": true,
|
||||
"temperature": {
|
||||
"ambient": 75,
|
||||
"pa": 105
|
||||
},
|
||||
"userApp": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 60
|
||||
}
|
||||
}
|
||||
},
|
||||
"retention": [
|
||||
{
|
||||
"maxEventRetentionTimeInMin": 500,
|
||||
"maxNumEvents": 150000,
|
||||
"throttle": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { Express } from "express";
|
||||
|
||||
import { setupAuthRoutes } from "./auth/auth.routes.js";
|
||||
// import the routes and route setups
|
||||
import { setupApiDocsRoutes } from "./configs/scaler.config.js";
|
||||
import { setupDatamartRoutes } from "./datamart/datamart.routes.js";
|
||||
import { setupNotificationRoutes } from "./notification/notification.routes.js";
|
||||
import { setupOCPRoutes } from "./ocp/ocp.routes.js";
|
||||
import { setupOpendockRoutes } from "./opendock/opendock.routes.js";
|
||||
import { setupProdSqlRoutes } from "./prodSql/prodSql.routes.js";
|
||||
import { setupSystemRoutes } from "./system/system.routes.js";
|
||||
@@ -17,4 +20,6 @@ export const setupRoutes = (baseUrl: string, app: Express) => {
|
||||
setupAuthRoutes(baseUrl, app);
|
||||
setupUtilsRoutes(baseUrl, app);
|
||||
setupOpendockRoutes(baseUrl, app);
|
||||
setupNotificationRoutes(baseUrl, app);
|
||||
setupOCPRoutes(baseUrl, app);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,8 @@ import { db } from "./db/db.controller.js";
|
||||
import { dbCleanup } from "./db/dbCleanup.controller.js";
|
||||
import { type Setting, settings } from "./db/schema/settings.schema.js";
|
||||
import { createLogger } from "./logger/logger.controller.js";
|
||||
import { startNotifications } from "./notification/notification.controller.js";
|
||||
import { createNotifications } from "./notification/notifications.master.js";
|
||||
import { monitorReleaseChanges } from "./opendock/openDockRreleaseMonitor.utils.js";
|
||||
import { opendockSocketMonitor } from "./opendock/opendockSocketMonitor.utils.js";
|
||||
import { connectProdSql } from "./prodSql/prodSqlConnection.controller.js";
|
||||
@@ -47,6 +49,10 @@ const start = async () => {
|
||||
dbCleanup("jobs", 30),
|
||||
);
|
||||
createCronJob("logsCleanup", "0 15 5 * * *", () => dbCleanup("logs", 120));
|
||||
|
||||
// one shots only needed to run on server startups
|
||||
createNotifications();
|
||||
startNotifications();
|
||||
}, 5 * 1000);
|
||||
|
||||
server.listen(port, async () => {
|
||||
|
||||
@@ -8,8 +8,8 @@ type RoomDefinition<T = unknown> = {
|
||||
};
|
||||
|
||||
export const protectedRooms: any = {
|
||||
logs: { requiresAuth: true, role: "admin" },
|
||||
admin: { requiresAuth: true, role: "admin" },
|
||||
logs: { requiresAuth: true, role: ["admin", "systemAdmin"] },
|
||||
admin: { requiresAuth: true, role: ["admin", "systemAdmin"] },
|
||||
};
|
||||
|
||||
export const roomDefinition: Record<RoomId, RoomDefinition> = {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Server as HttpServer } from "node:http";
|
||||
//import { fileURLToPath } from "node:url";
|
||||
import { instrument } from "@socket.io/admin-ui";
|
||||
import { Server } from "socket.io";
|
||||
import { auth } from "utils/auth.utils.js";
|
||||
|
||||
import { createLogger } from "../logger/logger.controller.js";
|
||||
import { allowedOrigins } from "../utils/cors.utils.js";
|
||||
import { registerEmitter } from "./roomEmitter.socket.js";
|
||||
@@ -13,6 +13,7 @@ import { createRoomEmitter, preseedRoom } from "./roomService.socket.js";
|
||||
//const __dirname = dirname(__filename);
|
||||
const log = createLogger({ module: "socket.io", subModule: "setup" });
|
||||
|
||||
import { auth } from "../utils/auth.utils.js";
|
||||
//import type { Session, User } from "better-auth"; // adjust if needed
|
||||
import { protectedRooms } from "./roomDefinitions.socket.js";
|
||||
|
||||
@@ -87,7 +88,12 @@ export const setupSocketIORoutes = (baseUrl: string, server: HttpServer) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (config?.role && s.user?.role !== config.role) {
|
||||
const roles = Array.isArray(config.role) ? config.role : [config.role];
|
||||
|
||||
console.log(roles, s.user.role);
|
||||
|
||||
//if (config?.role && s.user?.role !== config.role) {
|
||||
if (config?.role && !roles.includes(s.user?.role)) {
|
||||
return s.emit("room-error", {
|
||||
room: rn,
|
||||
message: `Not authorized to be in room: ${rn}`,
|
||||
|
||||
@@ -9,7 +9,7 @@ const newSettings: NewSetting[] = [
|
||||
{
|
||||
name: "opendock_sync",
|
||||
value: "0",
|
||||
active: true,
|
||||
active: false,
|
||||
description: "Dock Scheduling system",
|
||||
moduleName: "opendock",
|
||||
settingType: "feature",
|
||||
@@ -19,7 +19,7 @@ const newSettings: NewSetting[] = [
|
||||
{
|
||||
name: "ocp",
|
||||
value: "1",
|
||||
active: true,
|
||||
active: false,
|
||||
description: "One click print",
|
||||
moduleName: "ocp",
|
||||
settingType: "feature",
|
||||
@@ -29,7 +29,7 @@ const newSettings: NewSetting[] = [
|
||||
{
|
||||
name: "ocme",
|
||||
value: "0",
|
||||
active: true,
|
||||
active: false,
|
||||
description: "Dayton Agv system",
|
||||
moduleName: "ocme",
|
||||
settingType: "feature",
|
||||
@@ -39,7 +39,7 @@ const newSettings: NewSetting[] = [
|
||||
{
|
||||
name: "demandManagement",
|
||||
value: "1",
|
||||
active: true,
|
||||
active: false,
|
||||
description: "Fake EDI System",
|
||||
moduleName: "demandManagement",
|
||||
settingType: "feature",
|
||||
@@ -49,7 +49,7 @@ const newSettings: NewSetting[] = [
|
||||
{
|
||||
name: "qualityRequest",
|
||||
value: "0",
|
||||
active: true,
|
||||
active: false,
|
||||
description: "Quality System",
|
||||
moduleName: "qualityRequest",
|
||||
settingType: "feature",
|
||||
@@ -59,7 +59,7 @@ const newSettings: NewSetting[] = [
|
||||
{
|
||||
name: "tms",
|
||||
value: "0",
|
||||
active: true,
|
||||
active: false,
|
||||
description: "Transport system integration",
|
||||
moduleName: "tms",
|
||||
settingType: "feature",
|
||||
|
||||
26
backend/utils/auth.permissions.ts
Normal file
26
backend/utils/auth.permissions.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { createAccessControl } from "better-auth/plugins/access";
|
||||
|
||||
export const statement = {
|
||||
app: ["read", "create", "share", "update", "delete", "readAll"],
|
||||
user: ["ban"],
|
||||
quality: ["read", "create", "share", "update", "delete", "readAll"],
|
||||
notifications: ["read", "create", "share", "update", "delete", "readAll"],
|
||||
} as const;
|
||||
|
||||
export const ac = createAccessControl(statement);
|
||||
|
||||
export const user = ac.newRole({
|
||||
app: ["read", "create"],
|
||||
notifications: ["read", "create"],
|
||||
});
|
||||
|
||||
export const admin = ac.newRole({
|
||||
app: ["read", "create", "update"],
|
||||
});
|
||||
|
||||
export const systemAdmin = ac.newRole({
|
||||
app: ["read", "create", "share", "update", "delete", "readAll"],
|
||||
user: ["ban"],
|
||||
quality: ["read", "create", "share", "update", "delete", "readAll"],
|
||||
notifications: ["read", "create", "share", "update", "delete", "readAll"],
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import {
|
||||
admin,
|
||||
admin as adminPlugin,
|
||||
// apiKey,
|
||||
// createAuthMiddleware,
|
||||
//customSession,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
//import { eq } from "drizzle-orm";
|
||||
import { db } from "../db/db.controller.js";
|
||||
import * as rawSchema from "../db/schema/auth.schema.js";
|
||||
import { ac, admin, systemAdmin, user } from "./auth.permissions.js";
|
||||
import { allowedOrigins } from "./cors.utils.js";
|
||||
import { sendEmail } from "./sendEmail.utils.js";
|
||||
|
||||
@@ -44,7 +45,14 @@ export const auth = betterAuth({
|
||||
plugins: [
|
||||
jwt({ jwt: { expirationTime: "1h" } }),
|
||||
//apiKey(),
|
||||
admin(),
|
||||
adminPlugin({
|
||||
ac,
|
||||
roles: {
|
||||
admin,
|
||||
user,
|
||||
systemAdmin,
|
||||
},
|
||||
}),
|
||||
lastLoginMethod(),
|
||||
username({
|
||||
minUsernameLength: 5,
|
||||
@@ -77,7 +85,7 @@ export const auth = betterAuth({
|
||||
minPasswordLength: 8, // optional config
|
||||
resetPasswordTokenExpirySeconds: process.env.RESET_EXPIRY_SECONDS, // time in seconds
|
||||
sendResetPassword: async ({ user, token }) => {
|
||||
const frontendUrl = `${process.env.BETTER_AUTH_URL}/lst/app/user/resetpassword?token=${token}`;
|
||||
const frontendUrl = `${process.env.URL}/lst/app/user/resetpassword?token=${token}`;
|
||||
const expiryMinutes = Math.floor(
|
||||
parseInt(process.env.RESET_EXPIRY_SECONDS ?? "3600", 10) / 60,
|
||||
);
|
||||
@@ -137,5 +145,3 @@ export const auth = betterAuth({
|
||||
// },
|
||||
},
|
||||
});
|
||||
|
||||
type Session = typeof auth.$Infer.Session;
|
||||
|
||||
@@ -15,6 +15,7 @@ export const allowedOrigins = [
|
||||
`http://${process.env.PROD_SERVER}:3000`,
|
||||
`http://${process.env.PROD_SERVER}:3100`, // temp
|
||||
`http://usmcd1olp082:3000`,
|
||||
`${process.env.EXTERNAL_URL}`, // internal docker
|
||||
];
|
||||
export const lstCors = () => {
|
||||
return cors({
|
||||
|
||||
38
backend/utils/croner.minConvert.ts
Normal file
38
backend/utils/croner.minConvert.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { createLogger } from "../logger/logger.controller.js";
|
||||
|
||||
const minTime = 5;
|
||||
export const minutesToCron = (minutes: number): string => {
|
||||
const log = createLogger({ module: "system", subModule: "croner" });
|
||||
if (minutes < minTime) {
|
||||
log.error(
|
||||
{},
|
||||
`Conversion of less then ${minTime} min and is not allowed on this server`,
|
||||
);
|
||||
}
|
||||
|
||||
// Every X minutes (under 60)
|
||||
if (minutes < 60) {
|
||||
return `*/${minutes} * * * *`;
|
||||
}
|
||||
|
||||
// Every X hours (clean division)
|
||||
if (minutes % 60 === 0 && minutes < 1440) {
|
||||
const hours = minutes / 60;
|
||||
return `0 0 */${hours} * * *`;
|
||||
}
|
||||
|
||||
// Every day
|
||||
if (minutes === 1440) {
|
||||
return `0 0 8 * * *`; // 8am
|
||||
}
|
||||
|
||||
// Every X days (clean division)
|
||||
if (minutes % 1440 === 0) {
|
||||
const days = minutes / 1440;
|
||||
return `0 0 0 */${days} * *`;
|
||||
}
|
||||
|
||||
// Fallback (not cleanly divisible)
|
||||
// Cron can't represent this perfectly → run every X minutes as approximation
|
||||
return `0 */${minutes} * * * *`;
|
||||
};
|
||||
@@ -3,7 +3,14 @@ import { createLogger } from "../logger/logger.controller.js";
|
||||
|
||||
interface Data<T = unknown[]> {
|
||||
success: boolean;
|
||||
module: "system" | "ocp" | "routes" | "datamart" | "utils" | "opendock";
|
||||
module:
|
||||
| "system"
|
||||
| "ocp"
|
||||
| "routes"
|
||||
| "datamart"
|
||||
| "utils"
|
||||
| "opendock"
|
||||
| "notification";
|
||||
subModule:
|
||||
| "db"
|
||||
| "labeling"
|
||||
@@ -15,7 +22,14 @@ interface Data<T = unknown[]> {
|
||||
| "datamart"
|
||||
| "jobs"
|
||||
| "apt"
|
||||
| "settings";
|
||||
| "settings"
|
||||
| "get"
|
||||
| "update"
|
||||
| "delete"
|
||||
| "post"
|
||||
| "notification"
|
||||
| "delete"
|
||||
| "printing";
|
||||
level: "info" | "error" | "debug" | "fatal";
|
||||
message: string;
|
||||
room?: string;
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import type { Transporter } from "nodemailer";
|
||||
import nodemailer from "nodemailer";
|
||||
import type Mail from "nodemailer/lib/mailer/index.js";
|
||||
import type { Address } from "nodemailer/lib/mailer/index.js";
|
||||
import type SMTPTransport from "nodemailer/lib/smtp-transport/index.js";
|
||||
import hbs from "nodemailer-express-handlebars";
|
||||
import { returnFunc } from "./returnHelper.utils.js";
|
||||
import { tryCatch } from "./trycatch.utils.js";
|
||||
@@ -24,62 +21,36 @@ interface EmailData {
|
||||
}
|
||||
|
||||
export const sendEmail = async (data: EmailData) => {
|
||||
let transporter: Transporter;
|
||||
let fromEmail: string | Address;
|
||||
const fromEmail: string = `DoNotReply@mail.alpla.com`;
|
||||
const transporter: Transporter = nodemailer.createTransport({
|
||||
host: "smtp.azurecomm.net",
|
||||
port: 587,
|
||||
//rejectUnauthorized: false,
|
||||
tls: {
|
||||
minVersion: "TLSv1.2",
|
||||
},
|
||||
auth: {
|
||||
user: "donotreply@mail.alpla.com",
|
||||
pass: process.env.SMTP_PASSWORD,
|
||||
},
|
||||
debug: true,
|
||||
});
|
||||
|
||||
if (os.hostname().includes("OLP")) {
|
||||
transporter = nodemailer.createTransport({
|
||||
service: "gmail",
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASSWORD, // The 16-character App Password
|
||||
},
|
||||
//debug: true,
|
||||
});
|
||||
|
||||
// update the from email
|
||||
fromEmail = process.env.EMAIL_USER as string;
|
||||
|
||||
// update the from email
|
||||
fromEmail = `donotreply@alpla.com`;
|
||||
} else {
|
||||
//create the servers smtp config
|
||||
let host = `${os.hostname().replace("VMS006", "")}-smtp.alpla.net`;
|
||||
|
||||
if (os.hostname().includes("VMS036")) {
|
||||
host = "USMCD1-smtp.alpla.net";
|
||||
}
|
||||
|
||||
transporter = nodemailer.createTransport({
|
||||
host: host,
|
||||
port: 25,
|
||||
rejectUnauthorized: false,
|
||||
//secure: false,
|
||||
// auth: {
|
||||
// user: "alplaprod",
|
||||
// pass: "obelix",
|
||||
// },
|
||||
debug: true,
|
||||
} as SMTPTransport.Options);
|
||||
|
||||
// update the from email
|
||||
fromEmail = `donotreply@alpla.com`;
|
||||
}
|
||||
|
||||
// create the handlebars view
|
||||
// creating the handlbar options
|
||||
const viewPath = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"./mailViews",
|
||||
"../utils/mailViews/",
|
||||
);
|
||||
|
||||
const handlebarOptions = {
|
||||
viewEngine: {
|
||||
extname: ".hbs",
|
||||
defaultLayout: "",
|
||||
//layoutsDir: path.resolve(viewPath, "layouts"), // Path to layouts directory
|
||||
defaultLayout: "", // Specify the default layout
|
||||
partialsDir: viewPath,
|
||||
},
|
||||
viewPath: viewPath,
|
||||
extName: ".hbs",
|
||||
extName: ".hbs", // File extension for Handlebars templates
|
||||
};
|
||||
|
||||
transporter.use("compile", hbs(handlebarOptions));
|
||||
@@ -89,7 +60,7 @@ export const sendEmail = async (data: EmailData) => {
|
||||
to: data.email,
|
||||
subject: data.subject,
|
||||
//text: "You will have a reset token here and only have 30min to click the link before it expires.",
|
||||
//html: emailTemplate("Blake's Test", "This is an example with css"),
|
||||
//html: emailTemplate("BlakesTest", "This is an example with css"),
|
||||
template: data.template, // Name of the Handlebars template (e.g., 'welcome.hbs')
|
||||
context: data.context,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
vars {
|
||||
url: http://localhost:3000/lst
|
||||
readerIp: 10.44.14.215
|
||||
}
|
||||
vars:secret [
|
||||
token
|
||||
]
|
||||
|
||||
20
brunoApi/notifications/Get All notifications.bru
Normal file
20
brunoApi/notifications/Get All notifications.bru
Normal file
@@ -0,0 +1,20 @@
|
||||
meta {
|
||||
name: Get All notifications.
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{url}}/api/notification
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
|
||||
docs {
|
||||
Passing all as a query param will return all queries active and none active
|
||||
}
|
||||
24
brunoApi/notifications/Subscribe to notification.bru
Normal file
24
brunoApi/notifications/Subscribe to notification.bru
Normal file
@@ -0,0 +1,24 @@
|
||||
meta {
|
||||
name: Subscribe to notification
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{url}}/api/notification/sub
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"userId":"m6AbQXFwOXoX3YKLfwWgq2LIdDqS5jqv",
|
||||
"notificationId": "0399eb2a-39df-48b7-9f1c-d233cec94d2e",
|
||||
"emails": ["blake.mattes@alpla.com","cowchmonkey@gmail.com"]
|
||||
}
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
8
brunoApi/notifications/folder.bru
Normal file
8
brunoApi/notifications/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: notifications
|
||||
seq: 7
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
24
brunoApi/notifications/remove sub notification.bru
Normal file
24
brunoApi/notifications/remove sub notification.bru
Normal file
@@ -0,0 +1,24 @@
|
||||
meta {
|
||||
name: remove sub notification
|
||||
type: http
|
||||
seq: 4
|
||||
}
|
||||
|
||||
delete {
|
||||
url: {{url}}/api/notification/sub
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"userId":"0kHd6Kkdub4GW6rK1qa1yjWwqXtvykqT",
|
||||
"notificationId": "0399eb2a-39df-48b7-9f1c-d233cec94d2e",
|
||||
"emails": ["blake.mattes@alpla.com"]
|
||||
}
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
16
brunoApi/notifications/subscriptions.bru
Normal file
16
brunoApi/notifications/subscriptions.bru
Normal file
@@ -0,0 +1,16 @@
|
||||
meta {
|
||||
name: subscriptions
|
||||
type: http
|
||||
seq: 5
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{url}}/api/notification/sub
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
31
brunoApi/notifications/update notification.bru
Normal file
31
brunoApi/notifications/update notification.bru
Normal file
@@ -0,0 +1,31 @@
|
||||
meta {
|
||||
name: update notification
|
||||
type: http
|
||||
seq: 6
|
||||
}
|
||||
|
||||
patch {
|
||||
url: {{url}}/api/notification/:id
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
params:path {
|
||||
id: 0399eb2a-39df-48b7-9f1c-d233cec94d2e
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"active" : true,
|
||||
"options": []
|
||||
}
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
|
||||
docs {
|
||||
Passing all as a query param will return all queries active and none active
|
||||
}
|
||||
24
brunoApi/notifications/update sub notification.bru
Normal file
24
brunoApi/notifications/update sub notification.bru
Normal file
@@ -0,0 +1,24 @@
|
||||
meta {
|
||||
name: update sub notification
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
patch {
|
||||
url: {{url}}/api/notification/sub
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"userId":"m6AbQXFwOXoX3YKLfwWgq2LIdDqS5jqv",
|
||||
"notificationId": "0399eb2a-39df-48b7-9f1c-d233cec94d2e",
|
||||
"emails": ["cowchmonkey@gmail.com"]
|
||||
}
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
22
brunoApi/ocp/Printer Listenter.bru
Normal file
22
brunoApi/ocp/Printer Listenter.bru
Normal file
@@ -0,0 +1,22 @@
|
||||
meta {
|
||||
name: Printer Listenter
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{url}}/api/ocp/printer/listener/line_1
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"message":"xnvjdhhgsdfr"
|
||||
}
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
8
brunoApi/ocp/folder.bru
Normal file
8
brunoApi/ocp/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: ocp
|
||||
seq: 9
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
8
brunoApi/rfidReaders/folder.bru
Normal file
8
brunoApi/rfidReaders/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: rfidReaders
|
||||
seq: 8
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
20
brunoApi/rfidReaders/reader.bru
Normal file
20
brunoApi/rfidReaders/reader.bru
Normal file
@@ -0,0 +1,20 @@
|
||||
meta {
|
||||
name: reader
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
post {
|
||||
url: https://usday1prod.alpla.net/lst/old/api/rfid/mgtevents/line3.1
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:json {
|
||||
{}
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
20
brunoApi/rfidReaders/readerSpecific/Config.bru
Normal file
20
brunoApi/rfidReaders/readerSpecific/Config.bru
Normal file
@@ -0,0 +1,20 @@
|
||||
meta {
|
||||
name: Config
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://{{readerIp}}/cloud/config
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{token}}
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
32
brunoApi/rfidReaders/readerSpecific/Login.bru
Normal file
32
brunoApi/rfidReaders/readerSpecific/Login.bru
Normal file
@@ -0,0 +1,32 @@
|
||||
meta {
|
||||
name: Login
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://{{readerIp}}/cloud/localRestLogin
|
||||
body: none
|
||||
auth: basic
|
||||
}
|
||||
|
||||
auth:basic {
|
||||
username: admin
|
||||
password: Zebra123!
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
const body = res.getBody();
|
||||
|
||||
if (body.message) {
|
||||
bru.setEnvVar("token", body.message);
|
||||
} else {
|
||||
bru.setEnvVar("token", "error");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
237
brunoApi/rfidReaders/readerSpecific/Update Config.bru
Normal file
237
brunoApi/rfidReaders/readerSpecific/Update Config.bru
Normal file
@@ -0,0 +1,237 @@
|
||||
meta {
|
||||
name: Update Config
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
put {
|
||||
url: https://{{readerIp}}/cloud/config
|
||||
body: json
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
headers {
|
||||
Content-Type: application/json
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{token}}
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"GPIO-LED": {
|
||||
"GPODefaults": {
|
||||
"1": "HIGH",
|
||||
"2": "HIGH",
|
||||
"3": "HIGH",
|
||||
"4": "HIGH"
|
||||
},
|
||||
"LEDDefaults": {
|
||||
"3": "GREEN"
|
||||
},
|
||||
"TAG_READ": [
|
||||
{
|
||||
"pin": 1,
|
||||
"state": "HIGH",
|
||||
"type": "GPO"
|
||||
}
|
||||
]
|
||||
},
|
||||
"READER-GATEWAY": {
|
||||
"batching": [
|
||||
{
|
||||
"maxPayloadSizePerReport": 256000,
|
||||
"reportingInterval": 2000
|
||||
},
|
||||
{
|
||||
"maxPayloadSizePerReport": 256000,
|
||||
"reportingInterval": 2000
|
||||
}
|
||||
],
|
||||
"endpointConfig": {
|
||||
"data": {
|
||||
"event": {
|
||||
"connections": [
|
||||
{
|
||||
"additionalOptions": {
|
||||
"retention": {
|
||||
"maxEventRetentionTimeInMin": 500,
|
||||
"maxNumEvents": 150000,
|
||||
"throttle": 100
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"name": "LST",
|
||||
"options": {
|
||||
"URL": "https://usday1prod.alpla.net/lst/old/api/rfid/taginfo/line3.4",
|
||||
"security": {
|
||||
"CACertificateFileLocation": "",
|
||||
"authenticationOptions": {},
|
||||
"authenticationType": "NONE",
|
||||
"verifyHost": false,
|
||||
"verifyPeer": false
|
||||
}
|
||||
},
|
||||
"type": "httpPost"
|
||||
},
|
||||
{
|
||||
"additionalOptions": {
|
||||
"retention": {
|
||||
"maxEventRetentionTimeInMin": 500,
|
||||
"maxNumEvents": 150000,
|
||||
"throttle": 100
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"name": "mgt",
|
||||
"options": {
|
||||
"URL": "https://usday1prod.alpla.net/lst/old/api/rfid/mgtevents/line3.4",
|
||||
"security": {
|
||||
"CACertificateFileLocation": "",
|
||||
"authenticationOptions": {},
|
||||
"authenticationType": "NONE",
|
||||
"verifyHost": false,
|
||||
"verifyPeer": false
|
||||
}
|
||||
},
|
||||
"type": "httpPost"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"managementEventConfig": {
|
||||
"errors": {
|
||||
"antenna": false,
|
||||
"cpu": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 90
|
||||
},
|
||||
"database": true,
|
||||
"flash": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 90
|
||||
},
|
||||
"ntp": true,
|
||||
"radio": true,
|
||||
"radio_control": true,
|
||||
"ram": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 90
|
||||
},
|
||||
"reader_gateway": true,
|
||||
"userApp": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 120
|
||||
}
|
||||
},
|
||||
"gpiEvents": true,
|
||||
"gpoEvents": true,
|
||||
"heartbeat": {
|
||||
"fields": {
|
||||
"radio_control": [
|
||||
"ANTENNAS",
|
||||
"RADIO_ACTIVITY",
|
||||
"RADIO_CONNECTION",
|
||||
"CPU",
|
||||
"RAM",
|
||||
"UPTIME",
|
||||
"NUM_ERRORS",
|
||||
"NUM_WARNINGS",
|
||||
"NUM_TAG_READS",
|
||||
"NUM_TAG_READS_PER_ANTENNA",
|
||||
"NUM_DATA_MESSAGES_TXED",
|
||||
"NUM_RADIO_PACKETS_RXED"
|
||||
],
|
||||
"reader_gateway": [
|
||||
"NUM_DATA_MESSAGES_RXED",
|
||||
"NUM_MANAGEMENT_EVENTS_TXED",
|
||||
"NUM_DATA_MESSAGES_TXED",
|
||||
"NUM_DATA_MESSAGES_RETAINED",
|
||||
"NUM_DATA_MESSAGES_DROPPED",
|
||||
"CPU",
|
||||
"RAM",
|
||||
"UPTIME",
|
||||
"NUM_ERRORS",
|
||||
"NUM_WARNINGS",
|
||||
"INTERFACE_CONNECTION_STATUS",
|
||||
"NOLOCKQ_DEPTH"
|
||||
],
|
||||
"system": [
|
||||
"CPU",
|
||||
"FLASH",
|
||||
"NTP",
|
||||
"RAM",
|
||||
"SYSTEMTIME",
|
||||
"TEMPERATURE",
|
||||
"UPTIME",
|
||||
"GPO",
|
||||
"GPI",
|
||||
"POWER_NEGOTIATION",
|
||||
"POWER_SOURCE",
|
||||
"MAC_ADDRESS",
|
||||
"HOSTNAME"
|
||||
],
|
||||
"userapps": [
|
||||
"STATUS",
|
||||
"CPU",
|
||||
"RAM",
|
||||
"UPTIME",
|
||||
"NUM_DATA_MESSAGES_RXED",
|
||||
"NUM_DATA_MESSAGES_TXED",
|
||||
"INCOMING_DATA_BUFFER_PERCENTAGE_REMAINING",
|
||||
"OUTGOING_DATA_BUFFER_PERCENTAGE_REMAINING"
|
||||
]
|
||||
},
|
||||
"interval": 60
|
||||
},
|
||||
"userappEvents": true,
|
||||
"warnings": {
|
||||
"cpu": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 80
|
||||
},
|
||||
"database": true,
|
||||
"flash": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 80
|
||||
},
|
||||
"ntp": true,
|
||||
"radio_api": true,
|
||||
"radio_control": true,
|
||||
"ram": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 80
|
||||
},
|
||||
"reader_gateway": true,
|
||||
"temperature": {
|
||||
"ambient": 75,
|
||||
"pa": 105
|
||||
},
|
||||
"userApp": {
|
||||
"reportIntervalInSec": 1800,
|
||||
"threshold": 60
|
||||
}
|
||||
}
|
||||
},
|
||||
"retention": [
|
||||
{
|
||||
"maxEventRetentionTimeInMin": 500,
|
||||
"maxNumEvents": 150000,
|
||||
"throttle": 100
|
||||
},
|
||||
{
|
||||
"maxEventRetentionTimeInMin": 500,
|
||||
"maxNumEvents": 150000,
|
||||
"throttle": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
12
brunoApi/rfidReaders/readerSpecific/folder.bru
Normal file
12
brunoApi/rfidReaders/readerSpecific/folder.bru
Normal file
@@ -0,0 +1,12 @@
|
||||
meta {
|
||||
name: readerSpecific
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: basic
|
||||
}
|
||||
|
||||
auth:basic {
|
||||
username: admin
|
||||
password: Zebra123!
|
||||
}
|
||||
@@ -1,16 +1,21 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
lst:
|
||||
image: git.tuffraid.net/cowch/lst_v3:latest
|
||||
container_name: lst_app
|
||||
restart: unless-stopped
|
||||
# app:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
# container_name: lst_app
|
||||
ports:
|
||||
#- "${VITE_PORT:-4200}:4200"
|
||||
- "3600:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- LOG_LEVEL=info
|
||||
- DATABASE_HOST=host.docker.internal
|
||||
- EXTERNAL_URL=192.168.8.222:3600
|
||||
- DATABASE_HOST=host.docker.internal # if running on the same docker then do this
|
||||
- DATABASE_PORT=5433
|
||||
- DATABASE_USER=${DATABASE_USER}
|
||||
- DATABASE_PASSWORD=${DATABASE_PASSWORD}
|
||||
@@ -21,7 +26,6 @@ services:
|
||||
- PROD_PASSWORD=${PROD_PASSWORD}
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
|
||||
- BETTER_AUTH_URL=${URL}
|
||||
restart: unless-stopped
|
||||
# for all host including prod servers, plc's, printers, or other de
|
||||
# extra_hosts:
|
||||
# - "${PROD_SERVER}:${PROD_IP}"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/admin/settings')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/admin/settings"!</div>
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -2,9 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/lst.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
<title>Logistics Support Tool</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,37 +1,113 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Link, useNavigate, useRouterState } from "@tanstack/react-router";
|
||||
import { SidebarIcon } from "lucide-react";
|
||||
import { authClient, useSession } from "@/lib/auth-client";
|
||||
import { ModeToggle } from "./mode-toggle";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { useSidebar } from "./ui/sidebar";
|
||||
|
||||
export default function Header() {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
const { data: session } = useSession();
|
||||
const { signOut } = authClient;
|
||||
const router = useRouterState();
|
||||
const navigate = useNavigate();
|
||||
const currentPath = router.location.href;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 flex w-full items-center border-b bg-background">
|
||||
<div className="flex h-(--header-height) w-full items-center gap-2 px-4">
|
||||
<div className="flex flex-row">
|
||||
<Link to="/">
|
||||
<img
|
||||
src="/lst/app/imgs/dkLst.png"
|
||||
alt="Home"
|
||||
className="size-8 cursor-pointer"
|
||||
/>
|
||||
</Link>
|
||||
<div className="flex justify-between w-full">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<div className="flex flex-row">
|
||||
<Link to="/">
|
||||
<img
|
||||
src="/lst/app/imgs/dkLst.png"
|
||||
alt="Home"
|
||||
className="size-8 cursor-pointer"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<Button
|
||||
className="h-8 w-8"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 leading-none p-2">
|
||||
<span className="font-semibold text-2xl">Logistics Support Tool</span>
|
||||
</div>
|
||||
<div className="m-1 flex gap-1">
|
||||
<div>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
<div>
|
||||
{session ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Avatar>
|
||||
<AvatarImage
|
||||
src="https://github.com/evilrabbit.png"
|
||||
alt="@evilrabbit"
|
||||
/>
|
||||
<AvatarFallback>CN</AvatarFallback>
|
||||
</Avatar>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel>
|
||||
Hello {session.user?.name}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<Link to="/user/profile">Account</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* <DropdownMenuItem>Billing</DropdownMenuItem>
|
||||
<DropdownMenuItem>Team</DropdownMenuItem>
|
||||
<DropdownMenuItem>Subscription</DropdownMenuItem> */}
|
||||
<hr className="solid"></hr>
|
||||
<DropdownMenuItem>
|
||||
<div className="m-auto">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
navigate({ to: "/" });
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<div className="">
|
||||
<Button>
|
||||
<Link to="/login" search={{ redirect: currentPath }}>
|
||||
Login
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="h-8 w-8"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<SidebarIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 leading-none w-52">
|
||||
<span className="font-semibold">Logistics Support Tool</span>
|
||||
</div>
|
||||
<div className="m-1">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -11,17 +11,27 @@ import {
|
||||
useSidebar,
|
||||
} from "../ui/sidebar";
|
||||
|
||||
export default function AdminSidebar() {
|
||||
// type AdminSidebarProps = {
|
||||
// session: {
|
||||
// user: {
|
||||
// name?: string | null;
|
||||
// email?: string | null;
|
||||
// role?: string | string[];
|
||||
// };
|
||||
// } | null;
|
||||
//};
|
||||
|
||||
export default function AdminSidebar({ session }: any) {
|
||||
const { setOpen } = useSidebar();
|
||||
const items = [
|
||||
// {
|
||||
// title: "Users",
|
||||
// url: "/admin/users",
|
||||
// icon: User,
|
||||
// role: ["systemAdmin", "admin"],
|
||||
// module: "admin",
|
||||
// active: true,
|
||||
// },
|
||||
{
|
||||
title: "Settings",
|
||||
url: "/admin/settings",
|
||||
icon: Logs,
|
||||
role: ["systemAdmin"],
|
||||
module: "admin",
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
title: "Logs",
|
||||
url: "/admin/logs",
|
||||
@@ -53,14 +63,18 @@ export default function AdminSidebar() {
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link to={item.url} onClick={() => setOpen(false)}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<>
|
||||
{item.role.includes(session.user.role) && (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link to={item.url} onClick={() => setOpen(false)}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
|
||||
@@ -10,6 +10,7 @@ import AdminSidebar from "./AdminBar";
|
||||
|
||||
export function AppSidebar() {
|
||||
const { data: session } = useSession();
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
variant="sidebar"
|
||||
@@ -20,7 +21,11 @@ export function AppSidebar() {
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarContent>
|
||||
{session && session.user.role === "admin" && <AdminSidebar />}
|
||||
{session &&
|
||||
(session.user.role === "admin" ||
|
||||
session.user.role === "systemAdmin") && (
|
||||
<AdminSidebar session={session} />
|
||||
)}
|
||||
</SidebarContent>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
110
frontend/src/components/ui/avatar.tsx
Normal file
110
frontend/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
103
frontend/src/components/ui/card.tsx
Normal file
103
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
31
frontend/src/components/ui/checkbox.tsx
Normal file
31
frontend/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as React from "react"
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||
>
|
||||
<CheckIcon
|
||||
/>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
22
frontend/src/components/ui/label.tsx
Normal file
22
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
53
frontend/src/components/ui/scroll-area.tsx
Normal file
53
frontend/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
190
frontend/src/components/ui/select.tsx
Normal file
190
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
data-position={position}
|
||||
className={cn(
|
||||
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||
position === "popper" && ""
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
10
frontend/src/components/ui/spinner.tsx
Normal file
10
frontend/src/components/ui/spinner.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Loader2Icon } from "lucide-react"
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<Loader2Icon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
31
frontend/src/components/ui/switch.tsx
Normal file
31
frontend/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
114
frontend/src/components/ui/table.tsx
Normal file
114
frontend/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
88
frontend/src/components/ui/tabs.tsx
Normal file
88
frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
44
frontend/src/components/ui/toggle.tsx
Normal file
44
frontend/src/components/ui/toggle.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Toggle as TogglePrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"group/toggle inline-flex items-center justify-center gap-1 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted data-[state=on]:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border border-input bg-transparent hover:bg-muted",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 min-w-8 px-2",
|
||||
sm: "h-7 min-w-7 rounded-[min(var(--radius-md),12px)] px-1.5 text-[0.8rem]",
|
||||
lg: "h-9 min-w-9 px-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
@@ -1,9 +1,19 @@
|
||||
import { adminClient } from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { ac, admin, systemAdmin, user } from "./auth-permissions";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: `${window.location.origin}/lst/api/auth`,
|
||||
plugins: [adminClient()],
|
||||
plugins: [
|
||||
adminClient({
|
||||
ac,
|
||||
roles: {
|
||||
admin,
|
||||
user,
|
||||
systemAdmin,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export const { useSession, signUp, signIn, signOut } = authClient;
|
||||
|
||||
21
frontend/src/lib/auth-permissions.ts
Normal file
21
frontend/src/lib/auth-permissions.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createAccessControl } from "better-auth/plugins/access";
|
||||
|
||||
export const statement = {
|
||||
project: ["create", "share", "update", "delete"],
|
||||
user: ["ban"],
|
||||
} as const;
|
||||
|
||||
export const ac = createAccessControl(statement);
|
||||
|
||||
export const user = ac.newRole({
|
||||
project: ["create"],
|
||||
});
|
||||
|
||||
export const admin = ac.newRole({
|
||||
project: ["create", "update"],
|
||||
});
|
||||
|
||||
export const systemAdmin = ac.newRole({
|
||||
project: ["create", "update", "delete"],
|
||||
user: ["ban"],
|
||||
});
|
||||
33
frontend/src/lib/formSutff/CheckBox.Field.tsx
Normal file
33
frontend/src/lib/formSutff/CheckBox.Field.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Label } from "@radix-ui/react-label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useFieldContext } from ".";
|
||||
import { FieldErrors } from "./Errors.Field";
|
||||
|
||||
type CheckboxFieldProps = {
|
||||
label: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export const CheckboxField = ({ label }: CheckboxFieldProps) => {
|
||||
const field = useFieldContext<boolean>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor={field.name}>
|
||||
<span>{label}</span>
|
||||
</Label>
|
||||
|
||||
<Checkbox
|
||||
id={field.name}
|
||||
checked={field.state.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.handleChange(checked === true);
|
||||
}}
|
||||
onBlur={field.handleBlur}
|
||||
/>
|
||||
</div>
|
||||
<FieldErrors meta={field.state.meta} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
87
frontend/src/lib/formSutff/DynamicInput.Field.tsx
Normal file
87
frontend/src/lib/formSutff/DynamicInput.Field.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { useFieldContext } from ".";
|
||||
import { FieldErrors } from "./Errors.Field";
|
||||
|
||||
type DynamicInputField = {
|
||||
name?: string;
|
||||
label: string;
|
||||
inputType: "text" | "email" | "password" | "number" | "username";
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
addLabel?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const autoCompleteMap: Record<string, string> = {
|
||||
email: "email",
|
||||
password: "current-password",
|
||||
text: "off",
|
||||
username: "username",
|
||||
};
|
||||
|
||||
export const DynamicInputField = ({
|
||||
label,
|
||||
inputType = "text",
|
||||
required = false,
|
||||
description,
|
||||
addLabel,
|
||||
}: DynamicInputField) => {
|
||||
const field = useFieldContext<any>();
|
||||
const values = Array.isArray(field.state.value) ? field.state.value : [];
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 mt-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{label}</Label>
|
||||
{description ? (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
field.pushValue("");
|
||||
}}
|
||||
>
|
||||
{addLabel}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{values.map((_: string, index: number) => (
|
||||
<div key={`${field.name}-${index}`} className="grid gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={field.name}>{label}</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
autoComplete={autoCompleteMap[inputType] ?? "off"}
|
||||
value={field.state.value?.[index] ?? ""}
|
||||
onChange={(e) => field.replaceValue(index, e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
type={inputType}
|
||||
required={required}
|
||||
/>
|
||||
{values.length > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
size={"icon"}
|
||||
variant="destructive"
|
||||
onClick={() => field.removeValue(index)}
|
||||
>
|
||||
<Trash2 className="w-32 h-32" />
|
||||
</Button>
|
||||
) : null}
|
||||
<FieldErrors meta={field.state.meta} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
18
frontend/src/lib/formSutff/Errors.Field.tsx
Normal file
18
frontend/src/lib/formSutff/Errors.Field.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { AnyFieldMeta } from "@tanstack/react-form";
|
||||
|
||||
type FieldErrorsProps = {
|
||||
meta: AnyFieldMeta;
|
||||
};
|
||||
|
||||
export const FieldErrors = ({ meta }: FieldErrorsProps) => {
|
||||
if (!meta.isTouched) return null;
|
||||
|
||||
return meta.errors.map((error) => (
|
||||
<p
|
||||
key={`${error.message}-${error.code ?? "err"}`}
|
||||
className="text-sm font-medium text-destructive"
|
||||
>
|
||||
{error.message}
|
||||
</p>
|
||||
));
|
||||
};
|
||||
41
frontend/src/lib/formSutff/Input.Field.tsx
Normal file
41
frontend/src/lib/formSutff/Input.Field.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useFieldContext } from ".";
|
||||
import { FieldErrors } from "./Errors.Field";
|
||||
|
||||
type InputFieldProps = {
|
||||
label: string;
|
||||
inputType: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
const autoCompleteMap: Record<string, string> = {
|
||||
email: "email",
|
||||
password: "current-password",
|
||||
text: "off",
|
||||
username: "username",
|
||||
};
|
||||
|
||||
export const InputField = ({
|
||||
label,
|
||||
inputType,
|
||||
required = false,
|
||||
}: InputFieldProps) => {
|
||||
const field = useFieldContext<any>();
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 mt-2">
|
||||
<Label htmlFor={field.name}>{label}</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
autoComplete={autoCompleteMap[inputType] ?? "off"}
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
type={inputType}
|
||||
required={required}
|
||||
/>
|
||||
<FieldErrors meta={field.state.meta} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
49
frontend/src/lib/formSutff/InputPassword.Field.tsx
Normal file
49
frontend/src/lib/formSutff/InputPassword.Field.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useFieldContext } from ".";
|
||||
import { FieldErrors } from "./Errors.Field";
|
||||
|
||||
type PasswordInputProps = {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export const InputPasswordField = ({
|
||||
label,
|
||||
required = false,
|
||||
}: PasswordInputProps) => {
|
||||
const field = useFieldContext<any>();
|
||||
const [show, setShow] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 mt-2">
|
||||
<Label htmlFor={field.name}>{label}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={field.name}
|
||||
type={show ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
value={field.state.value}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
required={required}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShow((p) => !p)}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<FieldErrors meta={field.state.meta} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
57
frontend/src/lib/formSutff/Select.Field.tsx
Normal file
57
frontend/src/lib/formSutff/Select.Field.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Label } from "../../components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../components/ui/select";
|
||||
import { useFieldContext } from ".";
|
||||
import { FieldErrors } from "./Errors.Field";
|
||||
|
||||
type SelectOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type SelectFieldProps = {
|
||||
label: string;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export const SelectField = ({
|
||||
label,
|
||||
options,
|
||||
placeholder,
|
||||
}: SelectFieldProps) => {
|
||||
const field = useFieldContext<string>();
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor={field.name}>{label}</Label>
|
||||
<Select
|
||||
value={field.state.value}
|
||||
onValueChange={(value) => field.handleChange(value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={field.name}
|
||||
onBlur={field.handleBlur}
|
||||
className="w-min-2/3 w-max-fit"
|
||||
>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<FieldErrors meta={field.state.meta} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
42
frontend/src/lib/formSutff/SubmitButton.tsx
Normal file
42
frontend/src/lib/formSutff/SubmitButton.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useStore } from "@tanstack/react-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useFormContext } from ".";
|
||||
|
||||
type SubmitButtonProps = {
|
||||
children: React.ReactNode;
|
||||
variant?: "default" | "secondary" | "destructive";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const SubmitButton = ({
|
||||
children,
|
||||
variant = "default",
|
||||
className,
|
||||
}: SubmitButtonProps) => {
|
||||
const form = useFormContext();
|
||||
|
||||
const [isSubmitting] = useStore(form.store, (state) => [
|
||||
state.isSubmitting,
|
||||
state.canSubmit,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
variant={variant}
|
||||
className={className}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Spinner data-icon="inline-start" /> Submitting{" "}
|
||||
</>
|
||||
) : (
|
||||
<>{children}</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
29
frontend/src/lib/formSutff/Switch.Field.tsx
Normal file
29
frontend/src/lib/formSutff/Switch.Field.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import { useFieldContext } from ".";
|
||||
|
||||
type SwitchField = {
|
||||
trueLabel: string;
|
||||
falseLabel: string;
|
||||
};
|
||||
|
||||
export const SwitchField = ({
|
||||
trueLabel = "True",
|
||||
falseLabel = "False",
|
||||
}: SwitchField) => {
|
||||
const field = useFieldContext<boolean>();
|
||||
|
||||
const checked = field.state.value ?? false;
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id={field.name}
|
||||
checked={checked}
|
||||
onCheckedChange={field.handleChange}
|
||||
onBlur={field.handleBlur}
|
||||
/>
|
||||
<Label htmlFor={field.name}>{checked ? trueLabel : falseLabel}</Label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
28
frontend/src/lib/formSutff/index.tsx
Normal file
28
frontend/src/lib/formSutff/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createFormHook, createFormHookContexts } from "@tanstack/react-form";
|
||||
import { CheckboxField } from "./CheckBox.Field";
|
||||
import { DynamicInputField } from "./DynamicInput.Field";
|
||||
import { InputField } from "./Input.Field";
|
||||
import { InputPasswordField } from "./InputPassword.Field";
|
||||
import { SelectField } from "./Select.Field";
|
||||
import { SubmitButton } from "./SubmitButton";
|
||||
import { SwitchField } from "./Switch.Field";
|
||||
|
||||
export const { fieldContext, useFieldContext, formContext, useFormContext } =
|
||||
createFormHookContexts();
|
||||
|
||||
export const { useAppForm } = createFormHook({
|
||||
fieldComponents: {
|
||||
InputField,
|
||||
InputPasswordField,
|
||||
SelectField,
|
||||
CheckboxField,
|
||||
//DateField,
|
||||
//TextArea,
|
||||
//Searchable,
|
||||
SwitchField,
|
||||
DynamicInputField,
|
||||
},
|
||||
formComponents: { SubmitButton },
|
||||
fieldContext,
|
||||
formContext,
|
||||
});
|
||||
22
frontend/src/lib/queries/getSettings.ts
Normal file
22
frontend/src/lib/queries/getSettings.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
|
||||
export function getSettings() {
|
||||
return queryOptions({
|
||||
queryKey: ["getSettings"],
|
||||
queryFn: () => fetch(),
|
||||
staleTime: 5000,
|
||||
refetchOnWindowFocus: true,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
const fetch = async () => {
|
||||
if (window.location.hostname === "localhost") {
|
||||
await new Promise((res) => setTimeout(res, 5000));
|
||||
}
|
||||
|
||||
const { data } = await axios.get("/lst/api/settings");
|
||||
|
||||
return data.data;
|
||||
};
|
||||
24
frontend/src/lib/queries/notificationSubs.ts
Normal file
24
frontend/src/lib/queries/notificationSubs.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
|
||||
export function notificationSubs(userId?: string) {
|
||||
return queryOptions({
|
||||
queryKey: ["notificationSubs"],
|
||||
queryFn: () => fetch(userId),
|
||||
staleTime: 5000,
|
||||
refetchOnWindowFocus: true,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
const fetch = async (userId?: string) => {
|
||||
if (window.location.hostname === "localhost") {
|
||||
await new Promise((res) => setTimeout(res, 5000));
|
||||
}
|
||||
|
||||
const { data } = await axios.get(
|
||||
`/lst/api/notification/sub${userId ? `?userId=${userId}` : ""}`,
|
||||
);
|
||||
|
||||
return data.data;
|
||||
};
|
||||
22
frontend/src/lib/queries/notifications.ts
Normal file
22
frontend/src/lib/queries/notifications.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
|
||||
export function notifications() {
|
||||
return queryOptions({
|
||||
queryKey: ["notifications"],
|
||||
queryFn: () => fetch(),
|
||||
staleTime: 5000,
|
||||
refetchOnWindowFocus: true,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
const fetch = async () => {
|
||||
if (window.location.hostname === "localhost") {
|
||||
await new Promise((res) => setTimeout(res, 5000));
|
||||
}
|
||||
|
||||
const { data } = await axios.get("/lst/api/notification");
|
||||
|
||||
return data.data;
|
||||
};
|
||||
70
frontend/src/lib/tableStuff/EditableCellInput.tsx
Normal file
70
frontend/src/lib/tableStuff/EditableCellInput.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Input } from "../../components/ui/input";
|
||||
|
||||
type EditableCell = {
|
||||
value: string | number | null | undefined;
|
||||
id: string;
|
||||
field: string;
|
||||
className?: string;
|
||||
onSubmit: (args: { id: string; field: string; value: string }) => void;
|
||||
};
|
||||
|
||||
export default function EditableCellInput({
|
||||
value,
|
||||
id,
|
||||
field,
|
||||
className = "w-32",
|
||||
onSubmit,
|
||||
}: EditableCell) {
|
||||
const initialValue = String(value ?? "");
|
||||
const [localValue, setLocalValue] = useState(initialValue);
|
||||
const submitting = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
const handleSubmit = (nextValue: string) => {
|
||||
const trimmedValue = nextValue.trim();
|
||||
|
||||
if (trimmedValue === initialValue) return;
|
||||
|
||||
onSubmit({
|
||||
id,
|
||||
field,
|
||||
value: trimmedValue,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
value={localValue}
|
||||
className={className}
|
||||
onChange={(e) => setLocalValue(e.currentTarget.value)}
|
||||
onBlur={(e) => {
|
||||
if (submitting.current) return;
|
||||
|
||||
submitting.current = true;
|
||||
handleSubmit(e.currentTarget.value);
|
||||
setTimeout(() => {
|
||||
submitting.current = false;
|
||||
}, 100);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter") return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
if (submitting.current) return;
|
||||
|
||||
submitting.current = true;
|
||||
handleSubmit(e.currentTarget.value);
|
||||
e.currentTarget.blur();
|
||||
|
||||
setTimeout(() => {
|
||||
submitting.current = false;
|
||||
}, 100);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
129
frontend/src/lib/tableStuff/LstTable.tsx
Normal file
129
frontend/src/lib/tableStuff/LstTable.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
type ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { ScrollArea, ScrollBar } from "../../components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { cn } from "../utils";
|
||||
|
||||
type LstTableType = {
|
||||
className?: string;
|
||||
tableClassName?: string;
|
||||
data: any;
|
||||
columns: any;
|
||||
};
|
||||
export default function LstTable({
|
||||
className = "",
|
||||
tableClassName = "",
|
||||
data,
|
||||
columns,
|
||||
}: LstTableType) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
//console.log(data);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
//renderSubComponent: ({ row }: { row: any }) => <ExpandedRow row={row} />,
|
||||
//getRowCanExpand: () => true,
|
||||
filterFns: {},
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div className={className}>
|
||||
<ScrollArea className="w-full rounded-md border whitespace-nowrap">
|
||||
<Table className={cn("w-full", tableClassName)}>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<React.Fragment key={row.id}>
|
||||
<TableRow data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
frontend/src/lib/tableStuff/SearchableHeader.tsx
Normal file
67
frontend/src/lib/tableStuff/SearchableHeader.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { Column } from "@tanstack/react-table";
|
||||
import { ArrowDown, ArrowUp, Search } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "../../components/ui/dropdown-menu";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { cn } from "../utils";
|
||||
|
||||
type SearchableHeaderProps<TData> = {
|
||||
column: Column<TData, unknown>;
|
||||
title: string;
|
||||
searchable?: boolean;
|
||||
};
|
||||
|
||||
export default function SearchableHeader<TData>({
|
||||
column,
|
||||
title,
|
||||
searchable = false,
|
||||
}: SearchableHeaderProps<TData>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
<span className="flex flex-row items-center gap-2">
|
||||
{title}
|
||||
{column.getIsSorted() === "asc" ? (
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
) : column.getIsSorted() === "desc" ? (
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
) : null}
|
||||
</span>
|
||||
</Button>
|
||||
{searchable && (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Search
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
column.getFilterValue() ? "text-primary" : "",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start" className="w-56 p-2">
|
||||
<Input
|
||||
autoFocus
|
||||
value={(column.getFilterValue() as string) ?? ""}
|
||||
onChange={(e) => column.setFilterValue(e.target.value)}
|
||||
placeholder={`Search ${title.toLowerCase()}...`}
|
||||
className="h-8"
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
frontend/src/lib/tableStuff/SkellyTable.tsx
Normal file
40
frontend/src/lib/tableStuff/SkellyTable.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Skeleton } from "../../components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
|
||||
type TableSkelly = {
|
||||
rows?: number;
|
||||
columns?: number;
|
||||
};
|
||||
export default function SkellyTable({ rows = 5, columns = 4 }: TableSkelly) {
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{Array.from({ length: columns }).map((_, i) => (
|
||||
<TableHead key={i}>
|
||||
<Skeleton className="h-4 w-[80px]" />
|
||||
</TableHead>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<TableRow key={r}>
|
||||
{Array.from({ length: columns }).map((_, c) => (
|
||||
<TableCell key={c}>
|
||||
<Skeleton className="h-4 w-full max-w-[120px]" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const queryClient = new QueryClient({
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5,
|
||||
retry: 0,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnWindowFocus: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,7 +11,12 @@
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as AboutRouteImport } from './routes/about'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as AdminSettingsRouteImport } from './routes/admin/settings'
|
||||
import { Route as AdminLogsRouteImport } from './routes/admin/logs'
|
||||
import { Route as authLoginRouteImport } from './routes/(auth)/login'
|
||||
import { Route as authUserSignupRouteImport } from './routes/(auth)/user.signup'
|
||||
import { Route as authUserResetpasswordRouteImport } from './routes/(auth)/user.resetpassword'
|
||||
import { Route as authUserProfileRouteImport } from './routes/(auth)/user.profile'
|
||||
|
||||
const AboutRoute = AboutRouteImport.update({
|
||||
id: '/about',
|
||||
@@ -23,40 +28,110 @@ const IndexRoute = IndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AdminSettingsRoute = AdminSettingsRouteImport.update({
|
||||
id: '/admin/settings',
|
||||
path: '/admin/settings',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AdminLogsRoute = AdminLogsRouteImport.update({
|
||||
id: '/admin/logs',
|
||||
path: '/admin/logs',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const authLoginRoute = authLoginRouteImport.update({
|
||||
id: '/(auth)/login',
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const authUserSignupRoute = authUserSignupRouteImport.update({
|
||||
id: '/(auth)/user/signup',
|
||||
path: '/user/signup',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const authUserResetpasswordRoute = authUserResetpasswordRouteImport.update({
|
||||
id: '/(auth)/user/resetpassword',
|
||||
path: '/user/resetpassword',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const authUserProfileRoute = authUserProfileRouteImport.update({
|
||||
id: '/(auth)/user/profile',
|
||||
path: '/user/profile',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/login': typeof authLoginRoute
|
||||
'/admin/logs': typeof AdminLogsRoute
|
||||
'/admin/settings': typeof AdminSettingsRoute
|
||||
'/user/profile': typeof authUserProfileRoute
|
||||
'/user/resetpassword': typeof authUserResetpasswordRoute
|
||||
'/user/signup': typeof authUserSignupRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/login': typeof authLoginRoute
|
||||
'/admin/logs': typeof AdminLogsRoute
|
||||
'/admin/settings': typeof AdminSettingsRoute
|
||||
'/user/profile': typeof authUserProfileRoute
|
||||
'/user/resetpassword': typeof authUserResetpasswordRoute
|
||||
'/user/signup': typeof authUserSignupRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/about': typeof AboutRoute
|
||||
'/(auth)/login': typeof authLoginRoute
|
||||
'/admin/logs': typeof AdminLogsRoute
|
||||
'/admin/settings': typeof AdminSettingsRoute
|
||||
'/(auth)/user/profile': typeof authUserProfileRoute
|
||||
'/(auth)/user/resetpassword': typeof authUserResetpasswordRoute
|
||||
'/(auth)/user/signup': typeof authUserSignupRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/about' | '/admin/logs'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/about'
|
||||
| '/login'
|
||||
| '/admin/logs'
|
||||
| '/admin/settings'
|
||||
| '/user/profile'
|
||||
| '/user/resetpassword'
|
||||
| '/user/signup'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/about' | '/admin/logs'
|
||||
id: '__root__' | '/' | '/about' | '/admin/logs'
|
||||
to:
|
||||
| '/'
|
||||
| '/about'
|
||||
| '/login'
|
||||
| '/admin/logs'
|
||||
| '/admin/settings'
|
||||
| '/user/profile'
|
||||
| '/user/resetpassword'
|
||||
| '/user/signup'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/about'
|
||||
| '/(auth)/login'
|
||||
| '/admin/logs'
|
||||
| '/admin/settings'
|
||||
| '/(auth)/user/profile'
|
||||
| '/(auth)/user/resetpassword'
|
||||
| '/(auth)/user/signup'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AboutRoute: typeof AboutRoute
|
||||
authLoginRoute: typeof authLoginRoute
|
||||
AdminLogsRoute: typeof AdminLogsRoute
|
||||
AdminSettingsRoute: typeof AdminSettingsRoute
|
||||
authUserProfileRoute: typeof authUserProfileRoute
|
||||
authUserResetpasswordRoute: typeof authUserResetpasswordRoute
|
||||
authUserSignupRoute: typeof authUserSignupRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -75,6 +150,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/admin/settings': {
|
||||
id: '/admin/settings'
|
||||
path: '/admin/settings'
|
||||
fullPath: '/admin/settings'
|
||||
preLoaderRoute: typeof AdminSettingsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/admin/logs': {
|
||||
id: '/admin/logs'
|
||||
path: '/admin/logs'
|
||||
@@ -82,13 +164,46 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AdminLogsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(auth)/login': {
|
||||
id: '/(auth)/login'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof authLoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(auth)/user/signup': {
|
||||
id: '/(auth)/user/signup'
|
||||
path: '/user/signup'
|
||||
fullPath: '/user/signup'
|
||||
preLoaderRoute: typeof authUserSignupRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(auth)/user/resetpassword': {
|
||||
id: '/(auth)/user/resetpassword'
|
||||
path: '/user/resetpassword'
|
||||
fullPath: '/user/resetpassword'
|
||||
preLoaderRoute: typeof authUserResetpasswordRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(auth)/user/profile': {
|
||||
id: '/(auth)/user/profile'
|
||||
path: '/user/profile'
|
||||
fullPath: '/user/profile'
|
||||
preLoaderRoute: typeof authUserProfileRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AboutRoute: AboutRoute,
|
||||
authLoginRoute: authLoginRoute,
|
||||
AdminLogsRoute: AdminLogsRoute,
|
||||
AdminSettingsRoute: AdminSettingsRoute,
|
||||
authUserProfileRoute: authUserProfileRoute,
|
||||
authUserResetpasswordRoute: authUserResetpasswordRoute,
|
||||
authUserSignupRoute: authUserSignupRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
92
frontend/src/routes/(auth)/-components/ChangePassword.tsx
Normal file
92
frontend/src/routes/(auth)/-components/ChangePassword.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useAppForm } from "@/lib/formSutff";
|
||||
|
||||
export default function ChangePassword() {
|
||||
const router = useRouter();
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
if (value.newPassword !== value.confirmPassword) {
|
||||
toast.error("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
const { data, error } = await authClient.changePassword({
|
||||
newPassword: value.newPassword,
|
||||
currentPassword: value.currentPassword,
|
||||
revokeOtherSessions: true,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
toast.success("Password has been updated");
|
||||
form.reset();
|
||||
router.invalidate();
|
||||
|
||||
//navigate({ to: "/login" });
|
||||
}
|
||||
|
||||
if (error) {
|
||||
toast.success(error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<Card className="p-6 w-96">
|
||||
<CardHeader>
|
||||
<CardTitle>Change password</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.AppField name="currentPassword">
|
||||
{(field) => (
|
||||
<field.InputPasswordField
|
||||
label="Enter your current password"
|
||||
required={true}
|
||||
/>
|
||||
)}
|
||||
</form.AppField>
|
||||
|
||||
<form.AppField name="newPassword">
|
||||
{(field) => (
|
||||
<field.InputPasswordField
|
||||
label="New password"
|
||||
required={true}
|
||||
/>
|
||||
)}
|
||||
</form.AppField>
|
||||
|
||||
<form.AppField name="confirmPassword">
|
||||
{(field) => (
|
||||
<field.InputPasswordField
|
||||
label="Re-enter your password"
|
||||
required={true}
|
||||
/>
|
||||
)}
|
||||
</form.AppField>
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
<form.AppForm>
|
||||
<form.SubmitButton variant="destructive">
|
||||
Update Password
|
||||
</form.SubmitButton>
|
||||
</form.AppForm>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
frontend/src/routes/(auth)/-components/LoginForm.tsx
Normal file
117
frontend/src/routes/(auth)/-components/LoginForm.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useAppForm } from "@/lib/formSutff";
|
||||
import socket from "../../../lib/socket.io";
|
||||
|
||||
export default function LoginForm({ redirectPath }: { redirectPath: string }) {
|
||||
const loginEmail = localStorage.getItem("loginEmail") || "";
|
||||
const rememberMe = localStorage.getItem("rememberMe") === "true";
|
||||
const navigate = useNavigate();
|
||||
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
email: loginEmail,
|
||||
password: "",
|
||||
rememberMe: rememberMe,
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
// set remember me incase we want it later
|
||||
if (value.rememberMe) {
|
||||
localStorage.setItem("rememberMe", value.rememberMe.toString());
|
||||
localStorage.setItem("loginEmail", value.email);
|
||||
} else {
|
||||
localStorage.removeItem("rememberMe");
|
||||
localStorage.removeItem("loginEmail");
|
||||
}
|
||||
|
||||
try {
|
||||
const login = await authClient.signIn.email({
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
navigate({ to: redirectPath ?? "/" });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (login.error) {
|
||||
toast.error(`${login.error?.message}`);
|
||||
return;
|
||||
}
|
||||
toast.success(`Welcome back ${login.data?.user.name}`);
|
||||
if (login.data) {
|
||||
socket.disconnect();
|
||||
socket.connect();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card className="p-3 w-96">
|
||||
<CardHeader>
|
||||
<CardTitle>Login to your account</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your username and password below
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.AppField name="email">
|
||||
{(field) => (
|
||||
<field.InputField label="Email" inputType="email" required />
|
||||
)}
|
||||
</form.AppField>
|
||||
<form.AppField name="password">
|
||||
{(field) => (
|
||||
<field.InputPasswordField label="Password" required={true} />
|
||||
)}
|
||||
</form.AppField>
|
||||
|
||||
<div className="flex flex-row justify-between mt-3 mb-3">
|
||||
<form.AppField name="rememberMe">
|
||||
{(field) => <field.CheckboxField label="Remember me" />}
|
||||
</form.AppField>
|
||||
|
||||
<Link
|
||||
to="/user/resetpassword"
|
||||
className="inline-block text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-2">
|
||||
<form.AppForm>
|
||||
<form.SubmitButton>Login</form.SubmitButton>
|
||||
</form.AppForm>
|
||||
</div>
|
||||
</form>
|
||||
<div className="flex justify-center mt-2">
|
||||
Don't have an account?{" "}
|
||||
<Link to={"/user/signup"} className="underline underline-offset-4">
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import { useAppForm } from "../../../lib/formSutff";
|
||||
import { notificationSubs } from "../../../lib/queries/notificationSubs";
|
||||
import { notifications } from "../../../lib/queries/notifications";
|
||||
|
||||
export default function NotificationsSubCard({ user }: any) {
|
||||
const { data } = useSuspenseQuery(notifications());
|
||||
const { data: ns } = useSuspenseQuery(notificationSubs(user.id));
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
notificationId: "",
|
||||
emails: [user.email],
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
const postD = { ...value, userId: user.id };
|
||||
console.log(postD);
|
||||
},
|
||||
});
|
||||
|
||||
let n: any = [];
|
||||
if (data) {
|
||||
n = data.map((i: any) => ({
|
||||
label: i.name,
|
||||
value: i.id,
|
||||
}));
|
||||
}
|
||||
|
||||
console.log(ns);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card className="p-3 w-128">
|
||||
<CardHeader>
|
||||
<CardTitle>Notifications</CardTitle>
|
||||
<CardDescription>
|
||||
All currently active notifications you can subscribe to. selecting a
|
||||
notification will give you a brief description on how it works
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<form.AppField name="notificationId">
|
||||
{(field) => (
|
||||
<field.SelectField
|
||||
label="Notifications"
|
||||
placeholder="Select Notification"
|
||||
options={n}
|
||||
/>
|
||||
)}
|
||||
</form.AppField>
|
||||
</div>
|
||||
|
||||
<form.AppField name="emails" mode="array">
|
||||
{(field) => (
|
||||
<field.DynamicInputField
|
||||
label="Notification Emails"
|
||||
description="Add more email addresses for notification delivery."
|
||||
inputType="email"
|
||||
addLabel="Add Email"
|
||||
//initialValue={session?.user.email}
|
||||
/>
|
||||
)}
|
||||
</form.AppField>
|
||||
<div className="flex justify-end mt-6">
|
||||
<form.AppForm>
|
||||
<form.SubmitButton>Subscribe</form.SubmitButton>
|
||||
</form.AppForm>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
frontend/src/routes/(auth)/-components/ResetForm.tsx
Normal file
76
frontend/src/routes/(auth)/-components/ResetForm.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useAppForm } from "@/lib/formSutff";
|
||||
|
||||
export default function ResetForm() {
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
const { data, error } = await authClient.requestPasswordReset({
|
||||
email: value.email,
|
||||
redirectTo: `${window.location.origin}`,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
toast.success(data.message);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex justify-center mt-2">
|
||||
<Card className="p-6 w-96">
|
||||
<CardHeader>
|
||||
<CardTitle>Reset your password</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email address and we’ll send you a reset link
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.AppField name="email">
|
||||
{(field) => (
|
||||
<field.InputField label="Email" inputType="email" required />
|
||||
)}
|
||||
</form.AppField>
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
<form.AppForm>
|
||||
<form.SubmitButton>Send Reset Link</form.SubmitButton>
|
||||
</form.AppForm>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-gray-600">
|
||||
Remembered your password?{" "}
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-primary underline underline-offset-4 hover:text-primary/80"
|
||||
>
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
frontend/src/routes/(auth)/-components/ResetPassword.tsx
Normal file
94
frontend/src/routes/(auth)/-components/ResetPassword.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import { useAppForm } from "@/lib/formSutff";
|
||||
|
||||
export default function ResetPassword({ token }: { token: string }) {
|
||||
const form = useAppForm({
|
||||
defaultValues: {
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
if (!token) {
|
||||
toast.error("No token was included");
|
||||
return;
|
||||
}
|
||||
|
||||
const { data, error } = await authClient.resetPassword({
|
||||
newPassword: value.password,
|
||||
token,
|
||||
});
|
||||
|
||||
if (data) {
|
||||
toast.success("Password has been reset");
|
||||
}
|
||||
|
||||
if (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex justify-center mt-2">
|
||||
<Card className="p-6 w-96">
|
||||
<CardHeader>
|
||||
<CardTitle>Reset your password</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email address and we’ll send you a reset link
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
>
|
||||
<form.AppField name="password">
|
||||
{(field) => (
|
||||
<field.InputPasswordField
|
||||
label="New Password"
|
||||
required={true}
|
||||
/>
|
||||
)}
|
||||
</form.AppField>
|
||||
<form.AppField name="confirmPassword">
|
||||
{(field) => (
|
||||
<field.InputPasswordField
|
||||
label="Confirm Password"
|
||||
required={true}
|
||||
/>
|
||||
)}
|
||||
</form.AppField>
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
<form.AppForm>
|
||||
<form.SubmitButton>Send Reset Link</form.SubmitButton>
|
||||
</form.AppForm>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-gray-600">
|
||||
Remembered your password?{" "}
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-primary underline underline-offset-4 hover:text-primary/80"
|
||||
>
|
||||
Back to login
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
frontend/src/routes/(auth)/login.tsx
Normal file
32
frontend/src/routes/(auth)/login.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import z from "zod";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
import LoginForm from "./-components/LoginForm";
|
||||
|
||||
export const Route = createFileRoute("/(auth)/login")({
|
||||
component: RouteComponent,
|
||||
validateSearch: z.object({
|
||||
redirect: z.string().optional(),
|
||||
}),
|
||||
|
||||
beforeLoad: async () => {
|
||||
const result = await authClient.getSession({
|
||||
query: { disableCookieCache: true },
|
||||
});
|
||||
|
||||
if (result.data) {
|
||||
throw redirect({ to: "/" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const search = Route.useSearch();
|
||||
const redirectPath = search.redirect ?? "/";
|
||||
|
||||
return (
|
||||
<div className="flex justify-center mt-2">
|
||||
<LoginForm redirectPath={redirectPath} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user