Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,10 @@ NEXT_PUBLIC_GOOGLE_CLIENT_SECRET=your-client-secret
NEXT_PUBLIC_GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
NEXT_PUBLIC_GOOGLE_AUTH_URL=https://accounts.google.com/o/oauth2/v2/auth

SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=your-email@gmail.com
SMTP_PASSWORD=your-app-password
SMTP_FROM_EMAIL=your-email@gmail.com
FRONTEND_URL=http://localhost:3000

60 changes: 33 additions & 27 deletions frontend/app/components/APIKeysTable.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
'use client';
"use client";

import { useState, useEffect } from 'react';
import { fetchAPIKeys, deleteAPIKey, type APIKey } from '../lib/api-service';
import { Trash2 } from 'lucide-react';
import { useState, useEffect } from "react";
import { fetchAPIKeys, deleteAPIKey, type APIKey } from "../lib/api-service";
import { Trash2 } from "lucide-react";

// Re-export APIKey type for use in other components
export type { APIKey };

type SortOrder = 'asc' | 'desc';
type SortOrder = "asc" | "desc";

export default function APIKeysTable() {
const [apiKeys, setApiKeys] = useState<APIKey[]>([]);
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
Expand All @@ -20,7 +20,7 @@ export default function APIKeysTable() {
// Load API keys on mount
useEffect(() => {
loadAPIKeys();
},[]);
}, []);

const loadAPIKeys = async () => {
try {
Expand All @@ -29,7 +29,7 @@ export default function APIKeysTable() {
const keys = await fetchAPIKeys();
setApiKeys(keys);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load API keys');
setError(err instanceof Error ? err.message : "Failed to load API keys");
} finally {
setLoading(false);
}
Expand All @@ -46,13 +46,14 @@ export default function APIKeysTable() {
setDeleting(apiKeyId);
setDeleteError(null);
await deleteAPIKey(apiKeyId);

// Refetch the API keys list after successful deletion
await loadAPIKeys();
} catch (err) {
const errorMsg = err instanceof Error ? err.message : 'Failed to delete API key';
const errorMsg =
err instanceof Error ? err.message : "Failed to delete API key";
setDeleteError(errorMsg);
console.error('Delete error:', err);
console.error("Delete error:", err);
} finally {
setDeleting(null);
}
Expand All @@ -62,20 +63,20 @@ export default function APIKeysTable() {
const dateA = new Date(a.created_at).getTime();
const dateB = new Date(b.created_at).getTime();
const comparison = dateB - dateA;
return sortOrder === 'desc' ? comparison : -comparison;
return sortOrder === "desc" ? comparison : -comparison;
});

const toggleSortOrder = () => {
setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'));
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
};

const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};

Expand Down Expand Up @@ -118,7 +119,7 @@ function TableHeader({ sortOrder, onToggleSort }: TableHeaderProps) {
onClick={onToggleSort}
className="px-3 py-1 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors text-gray-700"
>
{sortOrder === 'desc' ? 'Newest First' : 'Oldest First'}
{sortOrder === "desc" ? "Newest First" : "Oldest First"}
</button>
</div>
</div>
Expand Down Expand Up @@ -169,7 +170,12 @@ interface TableBodyProps {
deleting: string | null;
}

function TableBody({ apiKeys, formatDate, onDelete, deleting }: TableBodyProps) {
function TableBody({
apiKeys,
formatDate,
onDelete,
deleting,
}: TableBodyProps) {
return (
<div className="overflow-x-auto">
<table className="w-full">
Expand Down Expand Up @@ -225,18 +231,18 @@ function TableRow({ apiKey, formatDate, onDelete, isDeleting }: TableRowProps) {
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
apiKey.is_active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{apiKey.is_active ? 'Active' : 'Inactive'}
{apiKey.is_active ? "Active" : "Inactive"}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
{formatDate(apiKey.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
{apiKey.revoked_at ? formatDate(apiKey.revoked_at) : '—'}
{apiKey.revoked_at ? formatDate(apiKey.revoked_at) : "—"}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm">
<button
Expand All @@ -245,9 +251,9 @@ function TableRow({ apiKey, formatDate, onDelete, isDeleting }: TableRowProps) {
className="inline-flex items-center gap-2 px-3 py-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Delete this API key"
>
{apiKey.is_active? <Trash2 size={16} /> : ''}
{apiKey.is_active ? <Trash2 size={16} /> : ""}
</button>
</td>
</tr>
);
}
}
121 changes: 98 additions & 23 deletions frontend/app/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,52 @@
'use client';
"use client";

import { motion } from 'framer-motion';
import Image from 'next/image';
import Link from 'next/link';
import { useState } from 'react';
import { motion } from "framer-motion";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { config } from "../lib/config";

export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [email, setEmail] = useState("");
const [isSubmitted, setIsSubmitted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// TODO: Implement password reset email logic
setIsSubmitted(true);
setError(null);
setIsLoading(true);

try {
const res = await fetch(
`${config.api.baseUrl}/api/v1/auth/forgot-password`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
}
);

if (!res.ok) {
const data = await res.json();
throw new Error(
data.detail || "Failed to send reset email. Please try again."
);
}

setIsSubmitted(true);
} catch (error) {
console.error("Error requesting password reset:", error);
setError(
error instanceof Error
? error.message
: "Failed to send reset email. Please try again."
);
} finally {
setIsLoading(false);
}
};

return (
Expand Down Expand Up @@ -43,8 +77,12 @@ export default function ForgotPasswordPage() {
<>
{/* Header */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Forgot Password?</h1>
<p className="text-gray-700">No worries, we&apos;ll send you reset instructions</p>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Forgot Password?
</h1>
<p className="text-gray-700">
No worries, we&apos;ll send you reset instructions
</p>
</div>

{/* Forgot Password Form */}
Expand All @@ -56,7 +94,10 @@ export default function ForgotPasswordPage() {
transition={{ delay: 0.4, duration: 0.6 }}
>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-900 mb-2">
<label
htmlFor="email"
className="block text-sm font-medium text-gray-900 mb-2"
>
Email Address
</label>
<motion.input
Expand All @@ -72,13 +113,24 @@ export default function ForgotPasswordPage() {
/>
</div>

{error && (
<motion.div
className="p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
>
{error}
</motion.div>
)}

<motion.button
type="submit"
className="w-full bg-orange-500 text-white py-3 rounded-lg font-medium hover:bg-orange-600 transition-all duration-200"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
disabled={isLoading}
className="w-full bg-orange-500 text-white py-3 rounded-lg font-medium hover:bg-orange-600 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
whileHover={!isLoading ? { scale: 1.02 } : {}}
whileTap={!isLoading ? { scale: 0.98 } : {}}
>
Reset Password
{isLoading ? "Sending..." : "Reset Password"}
</motion.button>
</motion.form>
</>
Expand All @@ -93,17 +145,30 @@ export default function ForgotPasswordPage() {
>
<div className="mb-6 flex justify-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
<svg
className="w-8 h-8 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">Check Your Email</h1>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Check Your Email
</h1>
<p className="text-gray-700 mb-6">
We&apos;ve sent password reset instructions to <span className="font-medium text-gray-900">{email}</span>
We&apos;ve sent password reset instructions to{" "}
<span className="font-medium text-gray-900">{email}</span>
</p>
<p className="text-sm text-gray-600 mb-8">
Didn&apos;t receive the email? Check your spam folder or{' '}
Didn&apos;t receive the email? Check your spam folder or{" "}
<button
onClick={() => setIsSubmitted(false)}
className="text-orange-500 hover:text-orange-600 font-medium transition-colors"
Expand All @@ -126,8 +191,18 @@ export default function ForgotPasswordPage() {
href="/login"
className="inline-flex items-center text-gray-700 hover:text-orange-500 font-medium transition-colors"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
<svg
className="w-4 h-4 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
Back to Login
</Link>
Expand Down
23 changes: 16 additions & 7 deletions frontend/app/lib/api-service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,23 @@
// lib/api-service.ts

import { clearAuthTokens } from "./auth";
import { config } from "./config";

const API_BASE_URL = `${config.api.baseUrl}/api/v1`;

const redirectToLogin = (reason: "expired" | "missing") => {
clearAuthTokens();
if (typeof window !== "undefined") {
const search = reason ? `?reason=${reason}` : "";
window.location.href = `/login${search}`;
}
throw new Error(
reason === "expired"
? "Session expired. Redirecting to login."
: "Not authenticated. Redirecting to login."
);
};

export interface APIKey {
id: string;
name: string;
Expand All @@ -17,9 +31,6 @@ interface APIKeysResponse {
total: number;
}

/**
* Get stored access token from localStorage
*/
export const getAccessToken = (): string | null => {
if (typeof window !== "undefined") {
return localStorage.getItem("access_token");
Expand All @@ -37,7 +48,7 @@ const makeAuthenticatedRequest = async (
const token = getAccessToken();

if (!token) {
throw new Error("No access token found. Please log in.");
redirectToLogin("missing");
}

const url = `${API_BASE_URL}${endpoint}`;
Expand All @@ -54,9 +65,7 @@ const makeAuthenticatedRequest = async (

if (!response.ok) {
if (response.status === 401) {
throw new Error(
"Unauthorized: Invalid or expired token. Please log in again."
);
redirectToLogin("expired");
}
if (response.status === 404) {
throw new Error("Resource not found.");
Expand Down
Loading