New API + API Token Updates
This commit is contained in:
@@ -12,13 +12,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.6",
|
"@astrojs/check": "^0.9.6",
|
||||||
"@astrojs/node": "^9.5.1",
|
"@astrojs/node": "^9.5.2",
|
||||||
"@astrojs/vue": "^5.1.3",
|
"@astrojs/vue": "^5.1.4",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"astro": "^5.16.6",
|
"astro": "^5.16.11",
|
||||||
"astro-icon": "^1.1.5",
|
"astro-icon": "^1.1.5",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.5.0",
|
"better-sqlite3": "^12.6.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"daisyui": "^5.5.14",
|
"daisyui": "^5.5.14",
|
||||||
"drizzle-orm": "0.45.1",
|
"drizzle-orm": "0.45.1",
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"vue-chartjs": "^5.3.3"
|
"vue-chartjs": "^5.3.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@catppuccin/daisyui": "^2.1.1",
|
||||||
"@iconify-json/heroicons": "^1.2.3",
|
"@iconify-json/heroicons": "^1.2.3",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
"@types/better-sqlite3": "^7.6.13",
|
||||||
"drizzle-kit": "0.31.8"
|
"drizzle-kit": "0.31.8"
|
||||||
|
|||||||
1174
pnpm-lock.yaml
generated
1174
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
327
src/db/schema.ts
327
src/db/schema.ts
@@ -1,135 +1,222 @@
|
|||||||
import { sqliteTable, text, integer, primaryKey, foreignKey } from 'drizzle-orm/sqlite-core';
|
import {
|
||||||
import { nanoid } from 'nanoid';
|
sqliteTable,
|
||||||
|
text,
|
||||||
|
integer,
|
||||||
|
primaryKey,
|
||||||
|
foreignKey,
|
||||||
|
} from "drizzle-orm/sqlite-core";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
export const users = sqliteTable('users', {
|
export const users = sqliteTable("users", {
|
||||||
id: text('id').primaryKey().$defaultFn(() => nanoid()),
|
id: text("id")
|
||||||
email: text('email').notNull().unique(),
|
.primaryKey()
|
||||||
passwordHash: text('password_hash').notNull(),
|
.$defaultFn(() => nanoid()),
|
||||||
name: text('name').notNull(),
|
email: text("email").notNull().unique(),
|
||||||
isSiteAdmin: integer('is_site_admin', { mode: 'boolean' }).default(false),
|
passwordHash: text("password_hash").notNull(),
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
|
name: text("name").notNull(),
|
||||||
|
isSiteAdmin: integer("is_site_admin", { mode: "boolean" }).default(false),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||||
|
() => new Date(),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const organizations = sqliteTable('organizations', {
|
export const organizations = sqliteTable("organizations", {
|
||||||
id: text('id').primaryKey().$defaultFn(() => nanoid()),
|
id: text("id")
|
||||||
name: text('name').notNull(),
|
.primaryKey()
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
|
.$defaultFn(() => nanoid()),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||||
|
() => new Date(),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const members = sqliteTable('members', {
|
export const members = sqliteTable(
|
||||||
userId: text('user_id').notNull(),
|
"members",
|
||||||
organizationId: text('organization_id').notNull(),
|
{
|
||||||
role: text('role').notNull().default('member'), // 'owner', 'admin', 'member'
|
userId: text("user_id").notNull(),
|
||||||
joinedAt: integer('joined_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
|
organizationId: text("organization_id").notNull(),
|
||||||
}, (table: any) => ({
|
role: text("role").notNull().default("member"), // 'owner', 'admin', 'member'
|
||||||
pk: primaryKey({ columns: [table.userId, table.organizationId] }),
|
joinedAt: integer("joined_at", { mode: "timestamp" }).$defaultFn(
|
||||||
userFk: foreignKey({
|
() => new Date(),
|
||||||
columns: [table.userId],
|
),
|
||||||
foreignColumns: [users.id]
|
},
|
||||||
|
(table: any) => ({
|
||||||
|
pk: primaryKey({ columns: [table.userId, table.organizationId] }),
|
||||||
|
userFk: foreignKey({
|
||||||
|
columns: [table.userId],
|
||||||
|
foreignColumns: [users.id],
|
||||||
|
}),
|
||||||
|
orgFk: foreignKey({
|
||||||
|
columns: [table.organizationId],
|
||||||
|
foreignColumns: [organizations.id],
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
orgFk: foreignKey({
|
);
|
||||||
columns: [table.organizationId],
|
|
||||||
foreignColumns: [organizations.id]
|
|
||||||
})
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const clients = sqliteTable('clients', {
|
export const clients = sqliteTable(
|
||||||
id: text('id').primaryKey().$defaultFn(() => nanoid()),
|
"clients",
|
||||||
organizationId: text('organization_id').notNull(),
|
{
|
||||||
name: text('name').notNull(),
|
id: text("id")
|
||||||
email: text('email'),
|
.primaryKey()
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
|
.$defaultFn(() => nanoid()),
|
||||||
}, (table: any) => ({
|
organizationId: text("organization_id").notNull(),
|
||||||
orgFk: foreignKey({
|
name: text("name").notNull(),
|
||||||
columns: [table.organizationId],
|
email: text("email"),
|
||||||
foreignColumns: [organizations.id]
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||||
})
|
() => new Date(),
|
||||||
}));
|
),
|
||||||
|
},
|
||||||
export const categories = sqliteTable('categories', {
|
(table: any) => ({
|
||||||
id: text('id').primaryKey().$defaultFn(() => nanoid()),
|
orgFk: foreignKey({
|
||||||
organizationId: text('organization_id').notNull(),
|
columns: [table.organizationId],
|
||||||
name: text('name').notNull(),
|
foreignColumns: [organizations.id],
|
||||||
color: text('color'),
|
}),
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
|
|
||||||
}, (table: any) => ({
|
|
||||||
orgFk: foreignKey({
|
|
||||||
columns: [table.organizationId],
|
|
||||||
foreignColumns: [organizations.id]
|
|
||||||
})
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const timeEntries = sqliteTable('time_entries', {
|
|
||||||
id: text('id').primaryKey().$defaultFn(() => nanoid()),
|
|
||||||
userId: text('user_id').notNull(),
|
|
||||||
organizationId: text('organization_id').notNull(),
|
|
||||||
clientId: text('client_id').notNull(),
|
|
||||||
categoryId: text('category_id').notNull(),
|
|
||||||
startTime: integer('start_time', { mode: 'timestamp' }).notNull(),
|
|
||||||
endTime: integer('end_time', { mode: 'timestamp' }),
|
|
||||||
description: text('description'),
|
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
|
|
||||||
}, (table: any) => ({
|
|
||||||
userFk: foreignKey({
|
|
||||||
columns: [table.userId],
|
|
||||||
foreignColumns: [users.id]
|
|
||||||
}),
|
}),
|
||||||
orgFk: foreignKey({
|
);
|
||||||
columns: [table.organizationId],
|
|
||||||
foreignColumns: [organizations.id]
|
export const categories = sqliteTable(
|
||||||
|
"categories",
|
||||||
|
{
|
||||||
|
id: text("id")
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
organizationId: text("organization_id").notNull(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
color: text("color"),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||||
|
() => new Date(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
(table: any) => ({
|
||||||
|
orgFk: foreignKey({
|
||||||
|
columns: [table.organizationId],
|
||||||
|
foreignColumns: [organizations.id],
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
clientFk: foreignKey({
|
);
|
||||||
columns: [table.clientId],
|
|
||||||
foreignColumns: [clients.id]
|
export const timeEntries = sqliteTable(
|
||||||
|
"time_entries",
|
||||||
|
{
|
||||||
|
id: text("id")
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
userId: text("user_id").notNull(),
|
||||||
|
organizationId: text("organization_id").notNull(),
|
||||||
|
clientId: text("client_id").notNull(),
|
||||||
|
categoryId: text("category_id").notNull(),
|
||||||
|
startTime: integer("start_time", { mode: "timestamp" }).notNull(),
|
||||||
|
endTime: integer("end_time", { mode: "timestamp" }),
|
||||||
|
description: text("description"),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||||
|
() => new Date(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
(table: any) => ({
|
||||||
|
userFk: foreignKey({
|
||||||
|
columns: [table.userId],
|
||||||
|
foreignColumns: [users.id],
|
||||||
|
}),
|
||||||
|
orgFk: foreignKey({
|
||||||
|
columns: [table.organizationId],
|
||||||
|
foreignColumns: [organizations.id],
|
||||||
|
}),
|
||||||
|
clientFk: foreignKey({
|
||||||
|
columns: [table.clientId],
|
||||||
|
foreignColumns: [clients.id],
|
||||||
|
}),
|
||||||
|
categoryFk: foreignKey({
|
||||||
|
columns: [table.categoryId],
|
||||||
|
foreignColumns: [categories.id],
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
categoryFk: foreignKey({
|
);
|
||||||
columns: [table.categoryId],
|
|
||||||
foreignColumns: [categories.id]
|
|
||||||
})
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const tags = sqliteTable('tags', {
|
export const tags = sqliteTable(
|
||||||
id: text('id').primaryKey().$defaultFn(() => nanoid()),
|
"tags",
|
||||||
organizationId: text('organization_id').notNull(),
|
{
|
||||||
name: text('name').notNull(),
|
id: text("id")
|
||||||
color: text('color'),
|
.primaryKey()
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
|
.$defaultFn(() => nanoid()),
|
||||||
}, (table: any) => ({
|
organizationId: text("organization_id").notNull(),
|
||||||
orgFk: foreignKey({
|
name: text("name").notNull(),
|
||||||
columns: [table.organizationId],
|
color: text("color"),
|
||||||
foreignColumns: [organizations.id]
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||||
})
|
() => new Date(),
|
||||||
}));
|
),
|
||||||
|
},
|
||||||
export const timeEntryTags = sqliteTable('time_entry_tags', {
|
(table: any) => ({
|
||||||
timeEntryId: text('time_entry_id').notNull(),
|
orgFk: foreignKey({
|
||||||
tagId: text('tag_id').notNull(),
|
columns: [table.organizationId],
|
||||||
}, (table: any) => ({
|
foreignColumns: [organizations.id],
|
||||||
pk: primaryKey({ columns: [table.timeEntryId, table.tagId] }),
|
}),
|
||||||
timeEntryFk: foreignKey({
|
|
||||||
columns: [table.timeEntryId],
|
|
||||||
foreignColumns: [timeEntries.id]
|
|
||||||
}),
|
}),
|
||||||
tagFk: foreignKey({
|
);
|
||||||
columns: [table.tagId],
|
|
||||||
foreignColumns: [tags.id]
|
|
||||||
})
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const sessions = sqliteTable('sessions', {
|
export const timeEntryTags = sqliteTable(
|
||||||
id: text('id').primaryKey(),
|
"time_entry_tags",
|
||||||
userId: text('user_id').notNull(),
|
{
|
||||||
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
|
timeEntryId: text("time_entry_id").notNull(),
|
||||||
}, (table: any) => ({
|
tagId: text("tag_id").notNull(),
|
||||||
userFk: foreignKey({
|
},
|
||||||
columns: [table.userId],
|
(table: any) => ({
|
||||||
foreignColumns: [users.id]
|
pk: primaryKey({ columns: [table.timeEntryId, table.tagId] }),
|
||||||
})
|
timeEntryFk: foreignKey({
|
||||||
}));
|
columns: [table.timeEntryId],
|
||||||
|
foreignColumns: [timeEntries.id],
|
||||||
|
}),
|
||||||
|
tagFk: foreignKey({
|
||||||
|
columns: [table.tagId],
|
||||||
|
foreignColumns: [tags.id],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export const siteSettings = sqliteTable('site_settings', {
|
export const sessions = sqliteTable(
|
||||||
id: text('id').primaryKey().$defaultFn(() => nanoid()),
|
"sessions",
|
||||||
key: text('key').notNull().unique(),
|
{
|
||||||
value: text('value').notNull(),
|
id: text("id").primaryKey(),
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
|
userId: text("user_id").notNull(),
|
||||||
|
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||||
|
},
|
||||||
|
(table: any) => ({
|
||||||
|
userFk: foreignKey({
|
||||||
|
columns: [table.userId],
|
||||||
|
foreignColumns: [users.id],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const siteSettings = sqliteTable("site_settings", {
|
||||||
|
id: text("id")
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
key: text("key").notNull().unique(),
|
||||||
|
value: text("value").notNull(),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp" }).$defaultFn(
|
||||||
|
() => new Date(),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const apiTokens = sqliteTable(
|
||||||
|
"api_tokens",
|
||||||
|
{
|
||||||
|
id: text("id")
|
||||||
|
.primaryKey()
|
||||||
|
.$defaultFn(() => nanoid()),
|
||||||
|
userId: text("user_id").notNull(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
token: text("token").notNull().unique(),
|
||||||
|
scopes: text("scopes").notNull().default("*"),
|
||||||
|
lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
|
||||||
|
() => new Date(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
(table: any) => ({
|
||||||
|
userFk: foreignKey({
|
||||||
|
columns: [table.userId],
|
||||||
|
foreignColumns: [users.id],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|||||||
17
src/env.d.ts
vendored
17
src/env.d.ts
vendored
@@ -2,18 +2,19 @@
|
|||||||
/// <reference types="astro/client" />
|
/// <reference types="astro/client" />
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly PROD: boolean;
|
readonly PROD: boolean;
|
||||||
readonly DEV: boolean;
|
readonly DEV: boolean;
|
||||||
readonly MODE: string;
|
readonly MODE: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv;
|
readonly env: ImportMetaEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare namespace App {
|
declare namespace App {
|
||||||
interface Locals {
|
interface Locals {
|
||||||
user: import('./db/schema').User | null;
|
user: import("./db/schema").User | null;
|
||||||
session: import('./db/schema').Session | null;
|
session: import("./db/schema").Session | null;
|
||||||
}
|
scopes: string[] | null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-1 px-2 flex items-center gap-2">
|
<div class="flex-1 px-2 flex items-center gap-2">
|
||||||
<img src="/src/assets/logo.webp" alt="Chronus" class="h-8 w-8" />
|
<img src="/src/assets/logo.webp" alt="Chronus" class="h-8 w-8" />
|
||||||
<span class="text-xl font-bold bg-linear-to-r from-primary to-secondary bg-clip-text text-transparent">Chronus</span>
|
<span class="text-xl font-bold text-primary">Chronus</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ const currentTeam = userMemberships.find(m => m.organization.id === currentTeamI
|
|||||||
<ul class="menu bg-base-200 min-h-full w-80 p-4">
|
<ul class="menu bg-base-200 min-h-full w-80 p-4">
|
||||||
<!-- Sidebar content here -->
|
<!-- Sidebar content here -->
|
||||||
<li class="mb-6">
|
<li class="mb-6">
|
||||||
<a href="/dashboard" class="flex items-center gap-3 text-2xl font-bold bg-linear-to-r from-primary to-secondary bg-clip-text text-transparent pointer-events-none">
|
<a href="/dashboard" class="flex items-center gap-3 text-2xl font-bold text-primary">
|
||||||
<img src="/src/assets/logo.webp" alt="Chronus" class="h-10 w-10" />
|
<img src="/src/assets/logo.webp" alt="Chronus" class="h-10 w-10" />
|
||||||
Chronus
|
Chronus
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
44
src/lib/api-auth.ts
Normal file
44
src/lib/api-auth.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { db } from "../db";
|
||||||
|
import { apiTokens, users } from "../db/schema";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
export function hashToken(token: string): string {
|
||||||
|
return crypto.createHash("sha256").update(token).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateApiToken(token: string) {
|
||||||
|
const hashedToken = hashToken(token);
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.select({
|
||||||
|
user: users,
|
||||||
|
tokenData: apiTokens,
|
||||||
|
})
|
||||||
|
.from(apiTokens)
|
||||||
|
.innerJoin(users, eq(apiTokens.userId, users.id))
|
||||||
|
.where(eq(apiTokens.token, hashedToken))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last used at
|
||||||
|
await db
|
||||||
|
.update(apiTokens)
|
||||||
|
.set({ lastUsedAt: new Date() })
|
||||||
|
.where(eq(apiTokens.id, result.tokenData.id));
|
||||||
|
|
||||||
|
const scopes = result.tokenData.scopes.split(",").map((s) => s.trim());
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: result.user,
|
||||||
|
scopes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateApiToken(): string {
|
||||||
|
const buffer = crypto.randomBytes(32);
|
||||||
|
return "ch_" + buffer.toString("hex");
|
||||||
|
}
|
||||||
@@ -1,12 +1,27 @@
|
|||||||
import { defineMiddleware } from 'astro/middleware';
|
import { defineMiddleware } from "astro/middleware";
|
||||||
import { validateSession } from './lib/auth';
|
import { validateSession } from "./lib/auth";
|
||||||
|
import { validateApiToken } from "./lib/api-auth";
|
||||||
|
|
||||||
export const onRequest = defineMiddleware(async (context, next) => {
|
export const onRequest = defineMiddleware(async (context, next) => {
|
||||||
const sessionId = context.cookies.get('session_id')?.value;
|
const authHeader = context.request.headers.get("Authorization");
|
||||||
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
const result = await validateApiToken(token);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
context.locals.user = result.user;
|
||||||
|
context.locals.session = null;
|
||||||
|
context.locals.scopes = result.scopes;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = context.cookies.get("session_id")?.value;
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
context.locals.user = null;
|
context.locals.user = null;
|
||||||
context.locals.session = null;
|
context.locals.session = null;
|
||||||
|
context.locals.scopes = null;
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,10 +30,12 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|||||||
if (result) {
|
if (result) {
|
||||||
context.locals.user = result.user;
|
context.locals.user = result.user;
|
||||||
context.locals.session = result.session;
|
context.locals.session = result.session;
|
||||||
|
context.locals.scopes = null;
|
||||||
} else {
|
} else {
|
||||||
context.locals.user = null;
|
context.locals.user = null;
|
||||||
context.locals.session = null;
|
context.locals.session = null;
|
||||||
context.cookies.delete('session_id');
|
context.locals.scopes = null;
|
||||||
|
context.cookies.delete("session_id");
|
||||||
}
|
}
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -1,44 +1,67 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from "astro";
|
||||||
import { db } from '../../../../db';
|
import { db } from "../../../../db";
|
||||||
import { categories, members, timeEntries } from '../../../../db/schema';
|
import { categories, members, timeEntries } from "../../../../db/schema";
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ locals, redirect, params }) => {
|
export const POST: APIRoute = async ({ request, locals, redirect, params }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return new Response('Unauthorized', { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
|
let redirectTo: string | undefined;
|
||||||
|
|
||||||
const userOrg = await db.select()
|
if (request.headers.get("Content-Type")?.includes("application/json")) {
|
||||||
|
const body = await request.json();
|
||||||
|
redirectTo = body.redirectTo;
|
||||||
|
} else {
|
||||||
|
const formData = await request.formData();
|
||||||
|
redirectTo = formData.get("redirectTo")?.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const userOrg = await db
|
||||||
|
.select()
|
||||||
.from(members)
|
.from(members)
|
||||||
.where(eq(members.userId, user.id))
|
.where(eq(members.userId, user.id))
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!userOrg) {
|
if (!userOrg) {
|
||||||
return new Response('No organization found', { status: 400 });
|
return new Response("No organization found", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = userOrg.role === 'owner' || userOrg.role === 'admin';
|
const isAdmin = userOrg.role === "owner" || userOrg.role === "admin";
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return new Response('Forbidden', { status: 403 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasEntries = await db.select()
|
const hasEntries = await db
|
||||||
|
.select()
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.where(eq(timeEntries.categoryId, id!))
|
.where(eq(timeEntries.categoryId, id!))
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (hasEntries) {
|
if (hasEntries) {
|
||||||
return new Response('Cannot delete category with time entries', { status: 400 });
|
return new Response("Cannot delete category with time entries", {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.delete(categories)
|
await db
|
||||||
.where(and(
|
.delete(categories)
|
||||||
eq(categories.id, id!),
|
.where(
|
||||||
eq(categories.organizationId, userOrg.organizationId)
|
and(
|
||||||
));
|
eq(categories.id, id!),
|
||||||
|
eq(categories.organizationId, userOrg.organizationId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return redirect('/dashboard/team/settings');
|
if (locals.scopes) {
|
||||||
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(redirectTo || "/dashboard/team/settings");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,46 +1,72 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from "astro";
|
||||||
import { db } from '../../../../db';
|
import { db } from "../../../../db";
|
||||||
import { categories, members } from '../../../../db/schema';
|
import { categories, members } from "../../../../db/schema";
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals, redirect, params }) => {
|
export const POST: APIRoute = async ({ request, locals, redirect, params }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return new Response('Unauthorized', { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const formData = await request.formData();
|
let name: string | undefined;
|
||||||
const name = formData.get('name')?.toString();
|
let color: string | undefined;
|
||||||
const color = formData.get('color')?.toString();
|
let redirectTo: string | undefined;
|
||||||
|
|
||||||
if (!name) {
|
if (request.headers.get("Content-Type")?.includes("application/json")) {
|
||||||
return new Response('Name is required', { status: 400 });
|
const body = await request.json();
|
||||||
|
name = body.name;
|
||||||
|
color = body.color;
|
||||||
|
redirectTo = body.redirectTo;
|
||||||
|
} else {
|
||||||
|
const formData = await request.formData();
|
||||||
|
name = formData.get("name")?.toString();
|
||||||
|
color = formData.get("color")?.toString();
|
||||||
|
redirectTo = formData.get("redirectTo")?.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const userOrg = await db.select()
|
if (!name) {
|
||||||
|
return new Response("Name is required", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userOrg = await db
|
||||||
|
.select()
|
||||||
.from(members)
|
.from(members)
|
||||||
.where(eq(members.userId, user.id))
|
.where(eq(members.userId, user.id))
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!userOrg) {
|
if (!userOrg) {
|
||||||
return new Response('No organization found', { status: 400 });
|
return new Response("No organization found", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = userOrg.role === 'owner' || userOrg.role === 'admin';
|
const isAdmin = userOrg.role === "owner" || userOrg.role === "admin";
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return new Response('Forbidden', { status: 403 });
|
return new Response("Forbidden", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.update(categories)
|
await db
|
||||||
|
.update(categories)
|
||||||
.set({
|
.set({
|
||||||
name,
|
name,
|
||||||
color: color || null,
|
color: color || null,
|
||||||
})
|
})
|
||||||
.where(and(
|
.where(
|
||||||
eq(categories.id, id!),
|
and(
|
||||||
eq(categories.organizationId, userOrg.organizationId)
|
eq(categories.id, id!),
|
||||||
));
|
eq(categories.organizationId, userOrg.organizationId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return redirect('/dashboard/team/settings');
|
if (locals.scopes) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ success: true, id, name, color: color || null }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(redirectTo || "/dashboard/team/settings");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,38 +1,59 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from "astro";
|
||||||
import { db } from '../../../db';
|
import { db } from "../../../db";
|
||||||
import { categories, members } from '../../../db/schema';
|
import { categories, members } from "../../../db/schema";
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from "drizzle-orm";
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return new Response('Unauthorized', { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
let name: string | undefined;
|
||||||
const name = formData.get('name')?.toString();
|
let color: string | undefined;
|
||||||
const color = formData.get('color')?.toString();
|
let redirectTo: string | undefined;
|
||||||
|
|
||||||
|
if (request.headers.get("Content-Type")?.includes("application/json")) {
|
||||||
|
const body = await request.json();
|
||||||
|
name = body.name;
|
||||||
|
color = body.color;
|
||||||
|
redirectTo = body.redirectTo;
|
||||||
|
} else {
|
||||||
|
const formData = await request.formData();
|
||||||
|
name = formData.get("name")?.toString();
|
||||||
|
color = formData.get("color")?.toString();
|
||||||
|
redirectTo = formData.get("redirectTo")?.toString();
|
||||||
|
}
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return new Response('Name is required', { status: 400 });
|
return new Response("Name is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userOrg = await db.select()
|
const userOrg = await db
|
||||||
|
.select()
|
||||||
.from(members)
|
.from(members)
|
||||||
.where(eq(members.userId, user.id))
|
.where(eq(members.userId, user.id))
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!userOrg) {
|
if (!userOrg) {
|
||||||
return new Response('No organization found', { status: 400 });
|
return new Response("No organization found", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const id = nanoid();
|
||||||
await db.insert(categories).values({
|
await db.insert(categories).values({
|
||||||
id: nanoid(),
|
id,
|
||||||
organizationId: userOrg.organizationId,
|
organizationId: userOrg.organizationId,
|
||||||
name,
|
name,
|
||||||
color: color || null,
|
color: color || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return redirect('/dashboard/team/settings');
|
if (locals.scopes) {
|
||||||
|
return new Response(JSON.stringify({ id, name, color: color || null }), {
|
||||||
|
status: 201,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(redirectTo || "/dashboard/team/settings");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,71 +1,100 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from "astro";
|
||||||
import { db } from '../../../../db';
|
import { db } from "../../../../db";
|
||||||
import { clients, members, timeEntries, timeEntryTags } from '../../../../db/schema';
|
import {
|
||||||
import { eq, and, inArray } from 'drizzle-orm';
|
clients,
|
||||||
|
members,
|
||||||
|
timeEntries,
|
||||||
|
timeEntryTags,
|
||||||
|
} from "../../../../db/schema";
|
||||||
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ params, locals, redirect }) => {
|
export const POST: APIRoute = async ({ params, locals, redirect }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return redirect('/login');
|
return redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return new Response('Client ID is required', { status: 400 });
|
return new Response("Client ID is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the client to check organization ownership
|
const client = await db
|
||||||
const client = await db.select()
|
.select()
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.where(eq(clients.id, id))
|
.where(eq(clients.id, id))
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return new Response('Client not found', { status: 404 });
|
if (locals.scopes) {
|
||||||
|
return new Response(JSON.stringify({ error: "Client not found" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response("Client not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify user is a member of the organization
|
const membership = await db
|
||||||
const membership = await db.select()
|
.select()
|
||||||
.from(members)
|
.from(members)
|
||||||
.where(and(
|
.where(
|
||||||
eq(members.userId, user.id),
|
and(
|
||||||
eq(members.organizationId, client.organizationId)
|
eq(members.userId, user.id),
|
||||||
))
|
eq(members.organizationId, client.organizationId),
|
||||||
|
),
|
||||||
|
)
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
return new Response('Not authorized', { status: 403 });
|
if (locals.scopes) {
|
||||||
|
return new Response(JSON.stringify({ error: "Not authorized" }), {
|
||||||
|
status: 403,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Response("Not authorized", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find all time entries for this client to clean up tags
|
const clientEntries = await db
|
||||||
const clientEntries = await db.select({ id: timeEntries.id })
|
.select({ id: timeEntries.id })
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.where(eq(timeEntries.clientId, id))
|
.where(eq(timeEntries.clientId, id))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const entryIds = clientEntries.map(e => e.id);
|
const entryIds = clientEntries.map((e) => e.id);
|
||||||
|
|
||||||
if (entryIds.length > 0) {
|
if (entryIds.length > 0) {
|
||||||
// Delete tags associated with these entries
|
await db
|
||||||
await db.delete(timeEntryTags)
|
.delete(timeEntryTags)
|
||||||
.where(inArray(timeEntryTags.timeEntryId, entryIds))
|
.where(inArray(timeEntryTags.timeEntryId, entryIds))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
// Delete the time entries
|
await db.delete(timeEntries).where(eq(timeEntries.clientId, id)).run();
|
||||||
await db.delete(timeEntries)
|
|
||||||
.where(eq(timeEntries.clientId, id))
|
|
||||||
.run();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the client
|
await db.delete(clients).where(eq(clients.id, id)).run();
|
||||||
await db.delete(clients)
|
|
||||||
.where(eq(clients.id, id))
|
|
||||||
.run();
|
|
||||||
|
|
||||||
return redirect('/dashboard/clients');
|
if (locals.scopes) {
|
||||||
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect("/dashboard/clients");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting client:', error);
|
console.error("Error deleting client:", error);
|
||||||
return new Response('Failed to delete client', { status: 500 });
|
if (locals.scopes) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Failed to delete client" }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Response("Failed to delete client", { status: 500 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,16 +14,24 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
|||||||
return new Response("Client ID is required", { status: 400 });
|
return new Response("Client ID is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
let name: string | undefined;
|
||||||
const name = formData.get("name") as string;
|
let email: string | undefined;
|
||||||
const email = formData.get("email") as string;
|
|
||||||
|
if (request.headers.get("Content-Type")?.includes("application/json")) {
|
||||||
|
const body = await request.json();
|
||||||
|
name = body.name;
|
||||||
|
email = body.email;
|
||||||
|
} else {
|
||||||
|
const formData = await request.formData();
|
||||||
|
name = formData.get("name")?.toString();
|
||||||
|
email = formData.get("email")?.toString();
|
||||||
|
}
|
||||||
|
|
||||||
if (!name || name.trim().length === 0) {
|
if (!name || name.trim().length === 0) {
|
||||||
return new Response("Client name is required", { status: 400 });
|
return new Response("Client name is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the client to check organization ownership
|
|
||||||
const client = await db
|
const client = await db
|
||||||
.select()
|
.select()
|
||||||
.from(clients)
|
.from(clients)
|
||||||
@@ -31,10 +39,15 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!client) {
|
if (!client) {
|
||||||
|
if (locals.scopes) {
|
||||||
|
return new Response(JSON.stringify({ error: "Client not found" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
return new Response("Client not found", { status: 404 });
|
return new Response("Client not found", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify user is a member of the organization
|
|
||||||
const membership = await db
|
const membership = await db
|
||||||
.select()
|
.select()
|
||||||
.from(members)
|
.from(members)
|
||||||
@@ -47,10 +60,15 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
|||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
|
if (locals.scopes) {
|
||||||
|
return new Response(JSON.stringify({ error: "Not authorized" }), {
|
||||||
|
status: 403,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
return new Response("Not authorized", { status: 403 });
|
return new Response("Not authorized", { status: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update client
|
|
||||||
await db
|
await db
|
||||||
.update(clients)
|
.update(clients)
|
||||||
.set({
|
.set({
|
||||||
@@ -60,9 +78,33 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
|
|||||||
.where(eq(clients.id, id))
|
.where(eq(clients.id, id))
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
|
if (locals.scopes) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
id,
|
||||||
|
name: name.trim(),
|
||||||
|
email: email?.trim() || null,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return redirect(`/dashboard/clients/${id}`);
|
return redirect(`/dashboard/clients/${id}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating client:", error);
|
console.error("Error updating client:", error);
|
||||||
|
if (locals.scopes) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Failed to update client" }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
return new Response("Failed to update client", { status: 500 });
|
return new Response("Failed to update client", { status: 500 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,38 +1,57 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from "astro";
|
||||||
import { db } from '../../../db';
|
import { db } from "../../../db";
|
||||||
import { clients, members } from '../../../db/schema';
|
import { clients, members } from "../../../db/schema";
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from "drizzle-orm";
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return new Response('Unauthorized', { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
let name: string | undefined;
|
||||||
const name = formData.get('name')?.toString();
|
let email: string | undefined;
|
||||||
const email = formData.get('email')?.toString();
|
|
||||||
|
if (request.headers.get("Content-Type")?.includes("application/json")) {
|
||||||
|
const body = await request.json();
|
||||||
|
name = body.name;
|
||||||
|
email = body.email;
|
||||||
|
} else {
|
||||||
|
const formData = await request.formData();
|
||||||
|
name = formData.get("name")?.toString();
|
||||||
|
email = formData.get("email")?.toString();
|
||||||
|
}
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return new Response('Name is required', { status: 400 });
|
return new Response("Name is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userOrg = await db.select()
|
const userOrg = await db
|
||||||
|
.select()
|
||||||
.from(members)
|
.from(members)
|
||||||
.where(eq(members.userId, user.id))
|
.where(eq(members.userId, user.id))
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!userOrg) {
|
if (!userOrg) {
|
||||||
return new Response('No organization found', { status: 400 });
|
return new Response("No organization found", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const id = nanoid();
|
||||||
|
|
||||||
await db.insert(clients).values({
|
await db.insert(clients).values({
|
||||||
id: nanoid(),
|
id,
|
||||||
organizationId: userOrg.organizationId,
|
organizationId: userOrg.organizationId,
|
||||||
name,
|
name,
|
||||||
email: email || null,
|
email: email || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return redirect('/dashboard/clients');
|
if (locals.scopes) {
|
||||||
|
return new Response(JSON.stringify({ id, name, email: email || null }), {
|
||||||
|
status: 201,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect("/dashboard/clients");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from "astro";
|
||||||
import { db } from '../../../db';
|
import { db } from "../../../db";
|
||||||
import { organizations, members } from '../../../db/schema';
|
import { organizations, members } from "../../../db/schema";
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return new Response('Unauthorized', { status: 401 });
|
return new Response("Unauthorized", { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
let name: string | undefined;
|
||||||
const name = formData.get('name')?.toString();
|
|
||||||
|
if (request.headers.get("Content-Type")?.includes("application/json")) {
|
||||||
|
const body = await request.json();
|
||||||
|
name = body.name;
|
||||||
|
} else {
|
||||||
|
const formData = await request.formData();
|
||||||
|
name = formData.get("name")?.toString();
|
||||||
|
}
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return new Response('Name is required', { status: 400 });
|
return new Response("Name is required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgId = nanoid();
|
const orgId = nanoid();
|
||||||
@@ -25,8 +32,15 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
|
|||||||
await db.insert(members).values({
|
await db.insert(members).values({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
role: 'owner',
|
role: "owner",
|
||||||
});
|
});
|
||||||
|
|
||||||
return redirect('/dashboard');
|
if (locals.scopes) {
|
||||||
|
return new Response(JSON.stringify({ id: orgId, name }), {
|
||||||
|
status: 201,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect("/dashboard");
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,34 +1,43 @@
|
|||||||
import type { APIRoute } from 'astro';
|
import type { APIRoute } from "astro";
|
||||||
import { db } from '../../../../db';
|
import { db } from "../../../../db";
|
||||||
import { timeEntries } from '../../../../db/schema';
|
import { timeEntries } from "../../../../db/schema";
|
||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ params, locals, redirect }) => {
|
export const POST: APIRoute = async ({ params, locals, redirect }) => {
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 });
|
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const entryId = params.id;
|
const entryId = params.id;
|
||||||
if (!entryId) {
|
if (!entryId) {
|
||||||
return new Response(JSON.stringify({ error: 'Entry ID required' }), { status: 400 });
|
return new Response(JSON.stringify({ error: "Entry ID required" }), {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = await db.select()
|
const entry = await db
|
||||||
|
.select()
|
||||||
.from(timeEntries)
|
.from(timeEntries)
|
||||||
.where(and(
|
.where(and(eq(timeEntries.id, entryId), eq(timeEntries.userId, user.id)))
|
||||||
eq(timeEntries.id, entryId),
|
|
||||||
eq(timeEntries.userId, user.id)
|
|
||||||
))
|
|
||||||
.get();
|
.get();
|
||||||
|
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return new Response(JSON.stringify({ error: 'Entry not found' }), { status: 404 });
|
return new Response(JSON.stringify({ error: "Entry not found" }), {
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.delete(timeEntries)
|
await db.delete(timeEntries).where(eq(timeEntries.id, entryId)).run();
|
||||||
.where(eq(timeEntries.id, entryId))
|
|
||||||
.run();
|
|
||||||
|
|
||||||
return redirect('/dashboard/tracker');
|
if (locals.scopes) {
|
||||||
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect("/dashboard/tracker");
|
||||||
};
|
};
|
||||||
|
|||||||
40
src/pages/api/user/tokens/[id].ts
Normal file
40
src/pages/api/user/tokens/[id].ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { db } from "../../../../db";
|
||||||
|
import { apiTokens } from "../../../../db/schema";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
|
export const DELETE: APIRoute = async ({ params, locals }) => {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = params;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return new Response(JSON.stringify({ error: "Token ID is required" }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.delete(apiTokens)
|
||||||
|
.where(and(eq(apiTokens.id, id), eq(apiTokens.userId, user.id)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
return new Response(JSON.stringify({ error: "Token not found" }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
};
|
||||||
49
src/pages/api/user/tokens/index.ts
Normal file
49
src/pages/api/user/tokens/index.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { APIRoute } from "astro";
|
||||||
|
import { db } from "../../../../db";
|
||||||
|
import { apiTokens } from "../../../../db/schema";
|
||||||
|
import { generateApiToken, hashToken } from "../../../../lib/api-auth";
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
|
const user = locals.user;
|
||||||
|
if (!user) {
|
||||||
|
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const name = formData.get("name")?.toString();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return new Response(JSON.stringify({ error: "Name is required" }), {
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawToken = generateApiToken();
|
||||||
|
const hashedToken = hashToken(rawToken);
|
||||||
|
|
||||||
|
const [newToken] = await db
|
||||||
|
.insert(apiTokens)
|
||||||
|
.values({
|
||||||
|
userId: user.id,
|
||||||
|
name,
|
||||||
|
token: hashedToken,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
...newToken,
|
||||||
|
token: rawToken,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 201,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
99
src/pages/dashboard/categories/[id]/edit.astro
Normal file
99
src/pages/dashboard/categories/[id]/edit.astro
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
---
|
||||||
|
import DashboardLayout from '../../../../layouts/DashboardLayout.astro';
|
||||||
|
import { Icon } from 'astro-icon/components';
|
||||||
|
import { db } from '../../../../db';
|
||||||
|
import { categories, members } from '../../../../db/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
|
||||||
|
const user = Astro.locals.user;
|
||||||
|
if (!user) return Astro.redirect('/login');
|
||||||
|
|
||||||
|
const { id } = Astro.params;
|
||||||
|
|
||||||
|
// Get current team from cookie
|
||||||
|
const currentTeamId = Astro.cookies.get('currentTeamId')?.value;
|
||||||
|
|
||||||
|
const userMemberships = await db.select()
|
||||||
|
.from(members)
|
||||||
|
.where(eq(members.userId, user.id))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
if (userMemberships.length === 0) return Astro.redirect('/dashboard');
|
||||||
|
|
||||||
|
// Use current team or fallback to first membership
|
||||||
|
const userMembership = currentTeamId
|
||||||
|
? userMemberships.find(m => m.organizationId === currentTeamId) || userMemberships[0]
|
||||||
|
: userMemberships[0];
|
||||||
|
|
||||||
|
const isAdmin = userMembership.role === 'owner' || userMembership.role === 'admin';
|
||||||
|
if (!isAdmin) return Astro.redirect('/dashboard/categories');
|
||||||
|
|
||||||
|
const category = await db.select()
|
||||||
|
.from(categories)
|
||||||
|
.where(and(
|
||||||
|
eq(categories.id, id!),
|
||||||
|
eq(categories.organizationId, userMembership.organizationId)
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (!category) return Astro.redirect('/dashboard/categories');
|
||||||
|
---
|
||||||
|
|
||||||
|
<DashboardLayout title="Edit Category - Chronus">
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<div class="flex items-center gap-3 mb-6">
|
||||||
|
<a href="/dashboard/categories" class="btn btn-ghost btn-sm">
|
||||||
|
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
<h1 class="text-3xl font-bold">Edit Category</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card bg-base-200 shadow-xl border border-base-300">
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="update-form" method="POST" action={`/api/categories/${id}/update`}>
|
||||||
|
<input type="hidden" name="redirectTo" value="/dashboard/categories" />
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label pb-2" for="name">
|
||||||
|
<span class="label-text font-medium">Category Name</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
value={category.name}
|
||||||
|
placeholder="Development"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control mt-4">
|
||||||
|
<label class="label pb-2" for="color">
|
||||||
|
<span class="label-text font-medium">Color (optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id="color"
|
||||||
|
name="color"
|
||||||
|
value={category.color || '#3b82f6'}
|
||||||
|
class="input input-bordered w-full h-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card-actions justify-between mt-6">
|
||||||
|
<form method="POST" action={`/api/categories/${id}/delete`} onsubmit="return confirm('Are you sure you want to delete this category?');">
|
||||||
|
<input type="hidden" name="redirectTo" value="/dashboard/categories" />
|
||||||
|
<button type="submit" class="btn btn-error btn-outline">Delete Category</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href="/dashboard/categories" class="btn btn-ghost">Cancel</a>
|
||||||
|
<button type="submit" form="update-form" class="btn btn-primary">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
54
src/pages/dashboard/categories/new.astro
Normal file
54
src/pages/dashboard/categories/new.astro
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
import DashboardLayout from '../../../layouts/DashboardLayout.astro';
|
||||||
|
import { Icon } from 'astro-icon/components';
|
||||||
|
|
||||||
|
const user = Astro.locals.user;
|
||||||
|
if (!user) return Astro.redirect('/login');
|
||||||
|
---
|
||||||
|
|
||||||
|
<DashboardLayout title="New Category - Chronus">
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<div class="flex items-center gap-3 mb-6">
|
||||||
|
<a href="/dashboard/categories" class="btn btn-ghost btn-sm">
|
||||||
|
<Icon name="heroicons:arrow-left" class="w-5 h-5" />
|
||||||
|
</a>
|
||||||
|
<h1 class="text-3xl font-bold">Add New Category</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="/api/categories/create" class="card bg-base-200 shadow-xl border border-base-300">
|
||||||
|
<input type="hidden" name="redirectTo" value="/dashboard/categories" />
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label pb-2" for="name">
|
||||||
|
<span class="label-text font-medium">Category Name</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
placeholder="Development"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label pb-2" for="color">
|
||||||
|
<span class="label-text font-medium">Color (optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
id="color"
|
||||||
|
name="color"
|
||||||
|
class="input input-bordered w-full h-12"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-end mt-6">
|
||||||
|
<a href="/dashboard/categories" class="btn btn-ghost">Cancel</a>
|
||||||
|
<button type="submit" class="btn btn-primary">Create Category</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
@@ -109,7 +109,7 @@ const hasMembership = userOrgs.length > 0;
|
|||||||
<DashboardLayout title="Dashboard - Chronus">
|
<DashboardLayout title="Dashboard - Chronus">
|
||||||
<div class="flex justify-between items-center mb-8">
|
<div class="flex justify-between items-center mb-8">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-4xl font-bold bg-linear-to-r from-primary to-secondary bg-clip-text text-transparent mb-2">
|
<h1 class="text-4xl font-bold text-primary mb-2">
|
||||||
Dashboard
|
Dashboard
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-base-content/60">Welcome back, {user.name}!</p>
|
<p class="text-base-content/60">Welcome back, {user.name}!</p>
|
||||||
|
|||||||
@@ -1,31 +1,40 @@
|
|||||||
---
|
---
|
||||||
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
import DashboardLayout from '../../layouts/DashboardLayout.astro';
|
||||||
import { Icon } from 'astro-icon/components';
|
import { Icon } from 'astro-icon/components';
|
||||||
|
import { db } from '../../db';
|
||||||
|
import { apiTokens } from '../../db/schema';
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
|
||||||
const user = Astro.locals.user;
|
const user = Astro.locals.user;
|
||||||
if (!user) return Astro.redirect('/login');
|
if (!user) return Astro.redirect('/login');
|
||||||
|
|
||||||
const url = new URL(Astro.request.url);
|
const url = new URL(Astro.request.url);
|
||||||
const successType = url.searchParams.get('success');
|
const successType = url.searchParams.get('success');
|
||||||
|
|
||||||
|
const userTokens = await db.select()
|
||||||
|
.from(apiTokens)
|
||||||
|
.where(eq(apiTokens.userId, user.id))
|
||||||
|
.orderBy(desc(apiTokens.createdAt))
|
||||||
|
.all();
|
||||||
---
|
---
|
||||||
|
|
||||||
<DashboardLayout title="Account Settings - Chronus">
|
<DashboardLayout title="Account Settings - Chronus">
|
||||||
<div class="max-w-4xl mx-auto px-4 sm:px-6">
|
<div class="max-w-4xl mx-auto px-4 sm:px-6">
|
||||||
<h1 class="text-2xl sm:text-3xl font-bold mb-6 sm:mb-8 bg-gradient-to-r from-primary to-secondary bg-clip-text text-transparent">
|
<h1 class="text-2xl sm:text-3xl font-bold mb-6 sm:mb-8 text-primary">
|
||||||
Account Settings
|
Account Settings
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Success Messages */}
|
{/* Success Messages */}
|
||||||
{successType === 'profile' && (
|
{successType === 'profile' && (
|
||||||
<div class="alert alert-success mb-6">
|
<div class="alert alert-success mb-6">
|
||||||
<Icon name="heroicons:check-circle" class="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
|
<Icon name="heroicons:check-circle" class="w-5 h-5 sm:w-6 sm:h-6 shrink-0" />
|
||||||
<span class="text-sm sm:text-base">Profile updated successfully!</span>
|
<span class="text-sm sm:text-base">Profile updated successfully!</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{successType === 'password' && (
|
{successType === 'password' && (
|
||||||
<div class="alert alert-success mb-6">
|
<div class="alert alert-success mb-6">
|
||||||
<Icon name="heroicons:check-circle" class="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
|
<Icon name="heroicons:check-circle" class="w-5 h-5 sm:w-6 sm:h-6 shrink-0" />
|
||||||
<span class="text-sm sm:text-base">Password changed successfully!</span>
|
<span class="text-sm sm:text-base">Password changed successfully!</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -37,39 +46,39 @@ const successType = url.searchParams.get('success');
|
|||||||
<Icon name="heroicons:user-circle" class="w-5 h-5 sm:w-6 sm:h-6" />
|
<Icon name="heroicons:user-circle" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||||
Profile Information
|
Profile Information
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<form action="/api/user/update-profile" method="POST" class="space-y-5">
|
<form action="/api/user/update-profile" method="POST" class="space-y-5">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label pb-2">
|
<label class="label pb-2">
|
||||||
<span class="label-text font-medium text-sm sm:text-base">Full Name</span>
|
<span class="label-text font-medium text-sm sm:text-base">Full Name</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
value={user.name}
|
value={user.name}
|
||||||
placeholder="Your full name"
|
placeholder="Your full name"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label pb-2">
|
<label class="label pb-2">
|
||||||
<span class="label-text font-medium text-sm sm:text-base">Email</span>
|
<span class="label-text font-medium text-sm sm:text-base">Email</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
value={user.email}
|
value={user.email}
|
||||||
placeholder="your@email.com"
|
placeholder="your@email.com"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
disabled
|
disabled
|
||||||
/>
|
/>
|
||||||
<div class="label pt-2">
|
<div class="label pt-2">
|
||||||
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Email cannot be changed</span>
|
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Email cannot be changed</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end pt-4">
|
<div class="flex justify-end pt-4">
|
||||||
<button type="submit" class="btn btn-primary w-full sm:w-auto">
|
<button type="submit" class="btn btn-primary w-full sm:w-auto">
|
||||||
<Icon name="heroicons:check" class="w-5 h-5" />
|
<Icon name="heroicons:check" class="w-5 h-5" />
|
||||||
@@ -87,52 +96,52 @@ const successType = url.searchParams.get('success');
|
|||||||
<Icon name="heroicons:key" class="w-5 h-5 sm:w-6 sm:h-6" />
|
<Icon name="heroicons:key" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||||
Change Password
|
Change Password
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<form action="/api/user/change-password" method="POST" class="space-y-5">
|
<form action="/api/user/change-password" method="POST" class="space-y-5">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label pb-2">
|
<label class="label pb-2">
|
||||||
<span class="label-text font-medium text-sm sm:text-base">Current Password</span>
|
<span class="label-text font-medium text-sm sm:text-base">Current Password</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
name="currentPassword"
|
name="currentPassword"
|
||||||
placeholder="Enter current password"
|
placeholder="Enter current password"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label pb-2">
|
<label class="label pb-2">
|
||||||
<span class="label-text font-medium text-sm sm:text-base">New Password</span>
|
<span class="label-text font-medium text-sm sm:text-base">New Password</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
name="newPassword"
|
name="newPassword"
|
||||||
placeholder="Enter new password"
|
placeholder="Enter new password"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
required
|
required
|
||||||
minlength="8"
|
minlength="8"
|
||||||
/>
|
/>
|
||||||
<div class="label pt-2">
|
<div class="label pt-2">
|
||||||
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Minimum 8 characters</span>
|
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Minimum 8 characters</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label pb-2">
|
<label class="label pb-2">
|
||||||
<span class="label-text font-medium text-sm sm:text-base">Confirm New Password</span>
|
<span class="label-text font-medium text-sm sm:text-base">Confirm New Password</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
name="confirmPassword"
|
name="confirmPassword"
|
||||||
placeholder="Confirm new password"
|
placeholder="Confirm new password"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
required
|
required
|
||||||
minlength="8"
|
minlength="8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end pt-4">
|
<div class="flex justify-end pt-4">
|
||||||
<button type="submit" class="btn btn-primary w-full sm:w-auto">
|
<button type="submit" class="btn btn-primary w-full sm:w-auto">
|
||||||
<Icon name="heroicons:lock-closed" class="w-5 h-5" />
|
<Icon name="heroicons:lock-closed" class="w-5 h-5" />
|
||||||
@@ -143,6 +152,64 @@ const successType = url.searchParams.get('success');
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- API Tokens -->
|
||||||
|
<div class="card bg-base-100 shadow-xl border border-base-200 mb-6">
|
||||||
|
<div class="card-body p-4 sm:p-6">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="card-title text-lg sm:text-xl">
|
||||||
|
<Icon name="heroicons:code-bracket-square" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||||
|
API Tokens
|
||||||
|
</h2>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="createTokenModal.showModal()">
|
||||||
|
<Icon name="heroicons:plus" class="w-4 h-4" />
|
||||||
|
Create Token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Last Used</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{userTokens.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center text-base-content/60 py-4">
|
||||||
|
No API tokens found. Create one to access the API.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
userTokens.map(token => (
|
||||||
|
<tr>
|
||||||
|
<td class="font-medium">{token.name}</td>
|
||||||
|
<td class="text-sm">
|
||||||
|
{token.lastUsedAt ? token.lastUsedAt.toLocaleDateString() : 'Never'}
|
||||||
|
</td>
|
||||||
|
<td class="text-sm">
|
||||||
|
{token.createdAt ? token.createdAt.toLocaleDateString() : 'N/A'}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
onclick={`deleteToken('${token.id}')`}
|
||||||
|
>
|
||||||
|
<Icon name="heroicons:trash" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Account Info -->
|
<!-- Account Info -->
|
||||||
<div class="card bg-base-100 shadow-xl border border-base-200">
|
<div class="card bg-base-100 shadow-xl border border-base-200">
|
||||||
<div class="card-body p-4 sm:p-6">
|
<div class="card-body p-4 sm:p-6">
|
||||||
@@ -150,7 +217,7 @@ const successType = url.searchParams.get('success');
|
|||||||
<Icon name="heroicons:information-circle" class="w-5 h-5 sm:w-6 sm:h-6" />
|
<Icon name="heroicons:information-circle" class="w-5 h-5 sm:w-6 sm:h-6" />
|
||||||
Account Information
|
Account Information
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex flex-col sm:flex-row sm:justify-between py-3 border-b border-base-300 gap-2 sm:gap-0">
|
<div class="flex flex-col sm:flex-row sm:justify-between py-3 border-b border-base-300 gap-2 sm:gap-0">
|
||||||
<span class="text-base-content/70 text-sm sm:text-base">Account ID</span>
|
<span class="text-base-content/70 text-sm sm:text-base">Account ID</span>
|
||||||
@@ -170,4 +237,133 @@ const successType = url.searchParams.get('success');
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Token Modal -->
|
||||||
|
<dialog id="createTokenModal" class="modal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg">Create API Token</h3>
|
||||||
|
<p class="py-4 text-sm text-base-content/70">
|
||||||
|
API tokens allow you to authenticate with the API programmatically.
|
||||||
|
Give your token a descriptive name.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form id="createTokenForm" method="dialog" class="space-y-4">
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label pb-2">
|
||||||
|
<span class="label-text font-medium">Token Name</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="name"
|
||||||
|
id="tokenName"
|
||||||
|
placeholder="e.g. CI/CD Pipeline"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn" onclick="createTokenModal.close()">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Generate Token</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button>close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Show Token Modal -->
|
||||||
|
<dialog id="showTokenModal" class="modal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg text-success flex items-center gap-2">
|
||||||
|
<Icon name="heroicons:check-circle" class="w-6 h-6" />
|
||||||
|
Token Created
|
||||||
|
</h3>
|
||||||
|
<p class="py-4">
|
||||||
|
Make sure to copy your personal access token now. You won't be able to see it again!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bg-base-200 p-4 rounded-lg break-all font-mono text-sm relative group">
|
||||||
|
<span id="newTokenDisplay"></span>
|
||||||
|
<button
|
||||||
|
class="absolute top-2 right-2 btn btn-xs btn-ghost opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onclick="copyToken()"
|
||||||
|
title="Copy to clipboard"
|
||||||
|
>
|
||||||
|
<Icon name="heroicons:clipboard" class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button class="btn btn-primary" onclick="closeShowTokenModal()">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script is:inline>
|
||||||
|
// Handle Token Creation
|
||||||
|
const createTokenForm = document.getElementById('createTokenForm');
|
||||||
|
|
||||||
|
createTokenForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const name = document.getElementById('tokenName').value;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('name', name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/tokens', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
document.getElementById('createTokenModal').close();
|
||||||
|
document.getElementById('newTokenDisplay').innerText = data.token;
|
||||||
|
document.getElementById('showTokenModal').showModal();
|
||||||
|
document.getElementById('tokenName').value = ''; // Reset form
|
||||||
|
} else {
|
||||||
|
alert('Failed to create token');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating token:', error);
|
||||||
|
alert('An error occurred');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle Token Copy
|
||||||
|
function copyToken() {
|
||||||
|
const token = document.getElementById('newTokenDisplay').innerText;
|
||||||
|
navigator.clipboard.writeText(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Closing Show Token Modal (refresh page to show new token in list)
|
||||||
|
function closeShowTokenModal() {
|
||||||
|
document.getElementById('showTokenModal').close();
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Token Deletion
|
||||||
|
async function deleteToken(id) {
|
||||||
|
if (!confirm('Are you sure you want to revoke this token? Any applications using it will stop working.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/user/tokens/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Failed to delete token');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting token:', error);
|
||||||
|
alert('An error occurred');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ if (Astro.locals.user) {
|
|||||||
<div class="hero-content text-center">
|
<div class="hero-content text-center">
|
||||||
<div class="max-w-4xl">
|
<div class="max-w-4xl">
|
||||||
<img src="/src/assets/logo.webp" alt="Chronus Logo" class="h-24 w-24 mx-auto mb-6" />
|
<img src="/src/assets/logo.webp" alt="Chronus Logo" class="h-24 w-24 mx-auto mb-6" />
|
||||||
<h1 class="text-6xl md:text-7xl font-bold mb-6 bg-linear-to-r from-primary to-secondary bg-clip-text text-transparent">
|
<h1 class="text-6xl md:text-7xl font-bold mb-6 text-primary">
|
||||||
Chronus
|
Chronus
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-xl md:text-2xl py-6 text-base-content/80 font-light max-w-2xl mx-auto">
|
<p class="text-xl md:text-2xl py-6 text-base-content/80 font-light max-w-2xl mx-auto">
|
||||||
|
|||||||
@@ -7,48 +7,48 @@ if (Astro.locals.user) {
|
|||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Login - Chronus">
|
<Layout title="Login - Chronus">
|
||||||
<div class="flex justify-center items-center min-h-screen bg-gradient-to-br from-base-100 via-base-200 to-base-300">
|
<div class="flex justify-center items-center min-h-screen bg-base-100">
|
||||||
<div class="card bg-base-100 shadow-2xl w-full max-w-md mx-4">
|
<div class="card bg-base-100 shadow-2xl w-full max-w-md mx-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<img src="/src/assets/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" />
|
<img src="/src/assets/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" />
|
||||||
<h2 class="text-3xl font-bold text-center mb-2">Welcome Back</h2>
|
<h2 class="text-3xl font-bold text-center mb-2">Welcome Back</h2>
|
||||||
<p class="text-center text-base-content/60 mb-6">Sign in to continue to Chronus</p>
|
<p class="text-center text-base-content/60 mb-6">Sign in to continue to Chronus</p>
|
||||||
|
|
||||||
<form action="/api/auth/login" method="POST" class="space-y-4">
|
<form action="/api/auth/login" method="POST" class="space-y-4">
|
||||||
<label class="form-control">
|
<label class="form-control">
|
||||||
<div class="label">
|
<div class="label">
|
||||||
<span class="label-text font-medium">Email</span>
|
<span class="label-text font-medium">Email</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
placeholder="your@email.com"
|
placeholder="your@email.com"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="form-control">
|
<label class="form-control">
|
||||||
<div class="label">
|
<div class="label">
|
||||||
<span class="label-text font-medium">Password</span>
|
<span class="label-text font-medium">Password</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button class="btn btn-primary w-full mt-6">Sign In</button>
|
<button class="btn btn-primary w-full mt-6">Sign In</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="divider">OR</div>
|
<div class="divider">OR</div>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-base-content/70">
|
<p class="text-sm text-base-content/70">
|
||||||
Don't have an account?
|
Don't have an account?
|
||||||
<a href="/signup" class="link link-primary font-semibold">Create one</a>
|
<a href="/signup" class="link link-primary font-semibold">Create one</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,13 +23,13 @@ if (!isFirstUser) {
|
|||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Sign Up - Chronus">
|
<Layout title="Sign Up - Chronus">
|
||||||
<div class="flex justify-center items-center min-h-screen bg-gradient-to-br from-base-100 via-base-200 to-base-300">
|
<div class="flex justify-center items-center min-h-screen bg-base-100">
|
||||||
<div class="card bg-base-100 shadow-2xl w-full max-w-md mx-4">
|
<div class="card bg-base-100 shadow-2xl w-full max-w-md mx-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<img src="/src/assets/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" />
|
<img src="/src/assets/logo.webp" alt="Chronus" class="h-16 w-16 mx-auto mb-4" />
|
||||||
<h2 class="text-3xl font-bold text-center mb-2">Create Account</h2>
|
<h2 class="text-3xl font-bold text-center mb-2">Create Account</h2>
|
||||||
<p class="text-center text-base-content/60 mb-6">Join Chronus to start tracking time</p>
|
<p class="text-center text-base-content/60 mb-6">Join Chronus to start tracking time</p>
|
||||||
|
|
||||||
{registrationDisabled ? (
|
{registrationDisabled ? (
|
||||||
<>
|
<>
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
@@ -39,7 +39,7 @@ if (!isFirstUser) {
|
|||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-base-content/70">
|
<p class="text-sm text-base-content/70">
|
||||||
Already have an account?
|
Already have an account?
|
||||||
<a href="/login" class="link link-primary font-semibold">Sign in</a>
|
<a href="/login" class="link link-primary font-semibold">Sign in</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,49 +51,49 @@ if (!isFirstUser) {
|
|||||||
<div class="label">
|
<div class="label">
|
||||||
<span class="label-text font-medium">Full Name</span>
|
<span class="label-text font-medium">Full Name</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="John Doe"
|
placeholder="John Doe"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="form-control">
|
<label class="form-control">
|
||||||
<div class="label">
|
<div class="label">
|
||||||
<span class="label-text font-medium">Email</span>
|
<span class="label-text font-medium">Email</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
name="email"
|
name="email"
|
||||||
placeholder="your@email.com"
|
placeholder="your@email.com"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="form-control">
|
<label class="form-control">
|
||||||
<div class="label">
|
<div class="label">
|
||||||
<span class="label-text font-medium">Password</span>
|
<span class="label-text font-medium">Password</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
name="password"
|
name="password"
|
||||||
placeholder="Create a strong password"
|
placeholder="Create a strong password"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button class="btn btn-primary w-full mt-6">Create Account</button>
|
<button class="btn btn-primary w-full mt-6">Create Account</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="divider">OR</div>
|
<div class="divider">OR</div>
|
||||||
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="text-sm text-base-content/70">
|
<p class="text-sm text-base-content/70">
|
||||||
Already have an account?
|
Already have an account?
|
||||||
<a href="/login" class="link link-primary font-semibold">Sign in</a>
|
<a href="/login" class="link link-primary font-semibold">Sign in</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "daisyui" {
|
@plugin "daisyui" {
|
||||||
themes: dark --default, light;
|
themes: false;
|
||||||
};
|
}
|
||||||
|
@plugin "./theme.ts";
|
||||||
|
|||||||
9
src/styles/theme.ts
Normal file
9
src/styles/theme.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { createCatppuccinPlugin } from "@catppuccin/daisyui";
|
||||||
|
|
||||||
|
export default createCatppuccinPlugin(
|
||||||
|
"mocha",
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user