New API + API Token Updates

This commit is contained in:
2026-01-16 13:20:11 -07:00
parent 756ab2a38f
commit 4412229990
26 changed files with 1661 additions and 1012 deletions

View File

@@ -1,44 +1,67 @@
import type { APIRoute } from 'astro';
import { db } from '../../../../db';
import { categories, members, timeEntries } from '../../../../db/schema';
import { eq, and } from 'drizzle-orm';
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { categories, members, timeEntries } from "../../../../db/schema";
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;
if (!user) {
return new Response('Unauthorized', { status: 401 });
return new Response("Unauthorized", { status: 401 });
}
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)
.where(eq(members.userId, user.id))
.get();
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) {
return new Response('Forbidden', { status: 403 });
return new Response("Forbidden", { status: 403 });
}
const hasEntries = await db.select()
const hasEntries = await db
.select()
.from(timeEntries)
.where(eq(timeEntries.categoryId, id!))
.get();
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)
.where(and(
eq(categories.id, id!),
eq(categories.organizationId, userOrg.organizationId)
));
await db
.delete(categories)
.where(
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");
};

View File

@@ -1,46 +1,72 @@
import type { APIRoute } from 'astro';
import { db } from '../../../../db';
import { categories, members } from '../../../../db/schema';
import { eq, and } from 'drizzle-orm';
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { categories, members } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
export const POST: APIRoute = async ({ request, locals, redirect, params }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
return new Response("Unauthorized", { status: 401 });
}
const { id } = params;
const formData = await request.formData();
const name = formData.get('name')?.toString();
const color = formData.get('color')?.toString();
let name: string | undefined;
let color: string | undefined;
let redirectTo: string | undefined;
if (!name) {
return new Response('Name is required', { status: 400 });
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();
}
const userOrg = await db.select()
if (!name) {
return new Response("Name is required", { status: 400 });
}
const userOrg = await db
.select()
.from(members)
.where(eq(members.userId, user.id))
.get();
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) {
return new Response('Forbidden', { status: 403 });
return new Response("Forbidden", { status: 403 });
}
await db.update(categories)
await db
.update(categories)
.set({
name,
color: color || null,
})
.where(and(
eq(categories.id, id!),
eq(categories.organizationId, userOrg.organizationId)
));
.where(
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, id, name, color: color || null }),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
);
}
return redirect(redirectTo || "/dashboard/team/settings");
};

View File

@@ -1,38 +1,59 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { categories, members } from '../../../db/schema';
import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { categories, members } from "../../../db/schema";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
return new Response("Unauthorized", { status: 401 });
}
const formData = await request.formData();
const name = formData.get('name')?.toString();
const color = formData.get('color')?.toString();
let name: string | undefined;
let color: string | undefined;
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) {
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)
.where(eq(members.userId, user.id))
.get();
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({
id: nanoid(),
id,
organizationId: userOrg.organizationId,
name,
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");
};

View File

@@ -1,71 +1,100 @@
import type { APIRoute } from 'astro';
import { db } from '../../../../db';
import { clients, members, timeEntries, timeEntryTags } from '../../../../db/schema';
import { eq, and, inArray } from 'drizzle-orm';
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import {
clients,
members,
timeEntries,
timeEntryTags,
} from "../../../../db/schema";
import { eq, and, inArray } from "drizzle-orm";
export const POST: APIRoute = async ({ params, locals, redirect }) => {
const user = locals.user;
if (!user) {
return redirect('/login');
return redirect("/login");
}
const { id } = params;
if (!id) {
return new Response('Client ID is required', { status: 400 });
return new Response("Client ID is required", { status: 400 });
}
try {
// Get the client to check organization ownership
const client = await db.select()
const client = await db
.select()
.from(clients)
.where(eq(clients.id, id))
.get();
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.select()
const membership = await db
.select()
.from(members)
.where(and(
eq(members.userId, user.id),
eq(members.organizationId, client.organizationId)
))
.where(
and(
eq(members.userId, user.id),
eq(members.organizationId, client.organizationId),
),
)
.get();
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.select({ id: timeEntries.id })
const clientEntries = await db
.select({ id: timeEntries.id })
.from(timeEntries)
.where(eq(timeEntries.clientId, id))
.all();
const entryIds = clientEntries.map(e => e.id);
const entryIds = clientEntries.map((e) => e.id);
if (entryIds.length > 0) {
// Delete tags associated with these entries
await db.delete(timeEntryTags)
await db
.delete(timeEntryTags)
.where(inArray(timeEntryTags.timeEntryId, entryIds))
.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) {
console.error('Error deleting client:', error);
return new Response('Failed to delete client', { status: 500 });
console.error("Error deleting client:", error);
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 });
}
};

View File

@@ -14,16 +14,24 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
return new Response("Client ID is required", { status: 400 });
}
const formData = await request.formData();
const name = formData.get("name") as string;
const email = formData.get("email") as string;
let name: string | undefined;
let email: string | undefined;
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) {
return new Response("Client name is required", { status: 400 });
}
try {
// Get the client to check organization ownership
const client = await db
.select()
.from(clients)
@@ -31,10 +39,15 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
.get();
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 });
}
// Verify user is a member of the organization
const membership = await db
.select()
.from(members)
@@ -47,10 +60,15 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
.get();
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 });
}
// Update client
await db
.update(clients)
.set({
@@ -60,9 +78,33 @@ export const POST: APIRoute = async ({ request, params, locals, redirect }) => {
.where(eq(clients.id, id))
.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}`);
} catch (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 });
}
};

View File

@@ -1,38 +1,57 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { clients, members } from '../../../db/schema';
import { eq } from 'drizzle-orm';
import { nanoid } from 'nanoid';
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { clients, members } from "../../../db/schema";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
return new Response("Unauthorized", { status: 401 });
}
const formData = await request.formData();
const name = formData.get('name')?.toString();
const email = formData.get('email')?.toString();
let name: string | undefined;
let email: string | undefined;
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) {
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)
.where(eq(members.userId, user.id))
.get();
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({
id: nanoid(),
id,
organizationId: userOrg.organizationId,
name,
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");
};

View File

@@ -1,19 +1,26 @@
import type { APIRoute } from 'astro';
import { db } from '../../../db';
import { organizations, members } from '../../../db/schema';
import { nanoid } from 'nanoid';
import type { APIRoute } from "astro";
import { db } from "../../../db";
import { organizations, members } from "../../../db/schema";
import { nanoid } from "nanoid";
export const POST: APIRoute = async ({ request, locals, redirect }) => {
const user = locals.user;
if (!user) {
return new Response('Unauthorized', { status: 401 });
return new Response("Unauthorized", { status: 401 });
}
const formData = await request.formData();
const name = formData.get('name')?.toString();
let name: string | undefined;
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) {
return new Response('Name is required', { status: 400 });
return new Response("Name is required", { status: 400 });
}
const orgId = nanoid();
@@ -25,8 +32,15 @@ export const POST: APIRoute = async ({ request, locals, redirect }) => {
await db.insert(members).values({
userId: user.id,
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");
};

View File

@@ -1,34 +1,43 @@
import type { APIRoute } from 'astro';
import { db } from '../../../../db';
import { timeEntries } from '../../../../db/schema';
import { eq, and } from 'drizzle-orm';
import type { APIRoute } from "astro";
import { db } from "../../../../db";
import { timeEntries } from "../../../../db/schema";
import { eq, and } from "drizzle-orm";
export const POST: APIRoute = async ({ params, locals, redirect }) => {
const user = locals.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;
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)
.where(and(
eq(timeEntries.id, entryId),
eq(timeEntries.userId, user.id)
))
.where(and(eq(timeEntries.id, entryId), eq(timeEntries.userId, user.id)))
.get();
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)
.where(eq(timeEntries.id, entryId))
.run();
await db.delete(timeEntries).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");
};

View 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" },
});
};

View 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",
},
},
);
};

View 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>

View 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>

View File

@@ -109,7 +109,7 @@ const hasMembership = userOrgs.length > 0;
<DashboardLayout title="Dashboard - Chronus">
<div class="flex justify-between items-center mb-8">
<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
</h1>
<p class="text-base-content/60">Welcome back, {user.name}!</p>

View File

@@ -1,31 +1,40 @@
---
import DashboardLayout from '../../layouts/DashboardLayout.astro';
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;
if (!user) return Astro.redirect('/login');
const url = new URL(Astro.request.url);
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">
<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
</h1>
{/* Success Messages */}
{successType === 'profile' && (
<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>
</div>
)}
{successType === 'password' && (
<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>
</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" />
Profile Information
</h2>
<form action="/api/user/update-profile" method="POST" class="space-y-5">
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Full Name</span>
</label>
<input
type="text"
name="name"
<input
type="text"
name="name"
value={user.name}
placeholder="Your full name"
class="input input-bordered w-full"
required
placeholder="Your full name"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Email</span>
</label>
<input
type="email"
name="email"
<input
type="email"
name="email"
value={user.email}
placeholder="your@email.com"
class="input input-bordered w-full"
placeholder="your@email.com"
class="input input-bordered w-full"
disabled
/>
<div class="label pt-2">
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Email cannot be changed</span>
</div>
</div>
<div class="flex justify-end pt-4">
<button type="submit" class="btn btn-primary w-full sm:w-auto">
<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" />
Change Password
</h2>
<form action="/api/user/change-password" method="POST" class="space-y-5">
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Current Password</span>
</label>
<input
type="password"
name="currentPassword"
placeholder="Enter current password"
class="input input-bordered w-full"
required
<input
type="password"
name="currentPassword"
placeholder="Enter current password"
class="input input-bordered w-full"
required
/>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">New Password</span>
</label>
<input
type="password"
name="newPassword"
placeholder="Enter new password"
class="input input-bordered w-full"
required
<input
type="password"
name="newPassword"
placeholder="Enter new password"
class="input input-bordered w-full"
required
minlength="8"
/>
<div class="label pt-2">
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Minimum 8 characters</span>
</div>
</div>
<div class="form-control">
<label class="label pb-2">
<span class="label-text font-medium text-sm sm:text-base">Confirm New Password</span>
</label>
<input
type="password"
name="confirmPassword"
placeholder="Confirm new password"
class="input input-bordered w-full"
required
<input
type="password"
name="confirmPassword"
placeholder="Confirm new password"
class="input input-bordered w-full"
required
minlength="8"
/>
</div>
<div class="flex justify-end pt-4">
<button type="submit" class="btn btn-primary w-full sm:w-auto">
<Icon name="heroicons:lock-closed" class="w-5 h-5" />
@@ -143,6 +152,64 @@ const successType = url.searchParams.get('success');
</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 -->
<div class="card bg-base-100 shadow-xl border border-base-200">
<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" />
Account Information
</h2>
<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">
<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>
<!-- 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>

View File

@@ -11,7 +11,7 @@ if (Astro.locals.user) {
<div class="hero-content text-center">
<div class="max-w-4xl">
<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
</h1>
<p class="text-xl md:text-2xl py-6 text-base-content/80 font-light max-w-2xl mx-auto">

View File

@@ -7,48 +7,48 @@ if (Astro.locals.user) {
---
<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-body">
<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>
<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">
<label class="form-control">
<div class="label">
<span class="label-text font-medium">Email</span>
</div>
<input
type="email"
name="email"
placeholder="your@email.com"
class="input input-bordered w-full"
required
<input
type="email"
name="email"
placeholder="your@email.com"
class="input input-bordered w-full"
required
/>
</label>
<label class="form-control">
<div class="label">
<span class="label-text font-medium">Password</span>
</div>
<input
type="password"
name="password"
placeholder="Enter your password"
class="input input-bordered w-full"
required
<input
type="password"
name="password"
placeholder="Enter your password"
class="input input-bordered w-full"
required
/>
</label>
<button class="btn btn-primary w-full mt-6">Sign In</button>
</form>
<div class="divider">OR</div>
<div class="text-center">
<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>
</p>
</div>

View File

@@ -23,13 +23,13 @@ if (!isFirstUser) {
---
<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-body">
<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>
<p class="text-center text-base-content/60 mb-6">Join Chronus to start tracking time</p>
{registrationDisabled ? (
<>
<div class="alert alert-warning">
@@ -39,7 +39,7 @@ if (!isFirstUser) {
<div class="divider"></div>
<div class="text-center">
<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>
</p>
</div>
@@ -51,49 +51,49 @@ if (!isFirstUser) {
<div class="label">
<span class="label-text font-medium">Full Name</span>
</div>
<input
type="text"
name="name"
placeholder="John Doe"
class="input input-bordered w-full"
required
<input
type="text"
name="name"
placeholder="John Doe"
class="input input-bordered w-full"
required
/>
</label>
<label class="form-control">
<div class="label">
<span class="label-text font-medium">Email</span>
</div>
<input
type="email"
name="email"
placeholder="your@email.com"
class="input input-bordered w-full"
required
<input
type="email"
name="email"
placeholder="your@email.com"
class="input input-bordered w-full"
required
/>
</label>
<label class="form-control">
<div class="label">
<span class="label-text font-medium">Password</span>
</div>
<input
type="password"
name="password"
placeholder="Create a strong password"
class="input input-bordered w-full"
required
<input
type="password"
name="password"
placeholder="Create a strong password"
class="input input-bordered w-full"
required
/>
</label>
<button class="btn btn-primary w-full mt-6">Create Account</button>
</form>
<div class="divider">OR</div>
<div class="text-center">
<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>
</p>
</div>