feat: setup auth flow to work in parallel with api key

Signed-off-by: Varun Raj <mailme@varunraj.in>
This commit is contained in:
Varun Raj committed 2024-09-03 08:11:25 +05:30
1 parent 2f19b5908d
commit 5e71e1eeeb
23 files changed
+296 -30

No files matched your search

BIN
View File
Binary file not shown.
+2
View File
@@ -35,6 +35,7 @@
"cmdk": "1.0.0",
"date-fns": "^3.6.0",
"drizzle-orm": "^0.33.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.428.0",
"next": "14.2.5",
"next-themes": "^0.3.0",
@@ -56,6 +57,7 @@
"@semantic-release/github": "^10.1.7",
"@semantic-release/npm": "^12.0.1",
"@semantic-release/release-notes-generator": "^14.0.1",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20",
"@types/pg": "^8.11.6",
"@types/react": "^18",
+88
View File
@@ -0,0 +1,88 @@
import Link from "next/link"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useState } from "react"
import { loginUser } from "@/handlers/api/user.handler"
export const description =
"A login form with email and password. There's an option to login with Google and a link to sign up if you don't have an account."
export function LoginForm() {
const [formData, setFormData] = useState({
email: "",
password: "",
})
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, [e.target.id]: e.target.value })
}
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setLoading(true)
loginUser(formData.email, formData.password).then((user) => {
console.log(user)
}).catch((error) => {
setErrorMessage(error.message)
}).finally(() => {
setLoading(false)
})
}
return (
<Card className="mx-auto max-w-sm">
<CardHeader>
<CardTitle className="text-2xl">Login</CardTitle>
<CardDescription>
Enter your email below to login to your account
</CardDescription>
</CardHeader>
<CardContent>
<form className="grid gap-4" onSubmit={handleSubmit}>
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="m@example.com"
required
onChange={handleChange}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
<Link href="#" className="ml-auto inline-block text-sm underline">
Forgot your password?
</Link>
</div>
<Input id="password" type="password" required
onChange={handleChange}
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
Login
</Button>
<Button variant="outline" className="w-full" disabled={loading}>
Login with Google
</Button>
</form>
</CardContent>
</Card>
)
}
+6 -2
View File
@@ -8,6 +8,7 @@ import ErrorBlock from "../shared/ErrorBlock";
import Loader from "../ui/loader";
import { ArrowRight, TriangleAlert } from "lucide-react";
import Link from "next/link";
import { LoginForm } from "../auth/LoginForm";
type RootLayoutProps = {
children: ReactNode;
@@ -25,7 +26,8 @@ export default function RootLayout({ children }: RootLayoutProps) {
return getMe()
.then(setUser)
.catch((error) => {
setErrorMessage(error);
if (error.status === 403) return setUser(null);
return setErrorMessage(error);
})
.finally(() => setLoading(false));
};
@@ -36,6 +38,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
if (loading) return <Loader />;
if (errorMessage)
return (
<div className="min-h-screen flex flex-col justify-center">
<ErrorBlock
@@ -57,7 +60,8 @@ export default function RootLayout({ children }: RootLayoutProps) {
/>
</div>
);
if (!user) return null;
if (!user) return <LoginForm />;
return (
<UserContext.Provider value={user}>
+3 -3
View File
@@ -76,12 +76,12 @@ CardFooter.displayName = "CardFooter"
interface CardProps {
title?: string
description?: string
className?: string
children: React.ReactNode
}
const Card = ({ children, title, description }: CardProps) => {
const Card = ({ children, title, description, className }: CardProps) => {
return (
<CardRoot>
<CardRoot className={className}>
{!!(title || description) && (
<CardHeader>
{title && <CardTitle>{title}</CardTitle>}
+6
View File
@@ -0,0 +1,6 @@
import { ENV } from "./environment";
export const appConfig = {
sessionCookieName: 'power-tools-session',
jwtToken: ENV.JWT_SECRET || "jwt-secret", // This is a fallback value, it should be replaced with a proper secret
}
+1
View File
@@ -3,6 +3,7 @@ export const ENV = {
EXTERNAL_IMMICH_URL: (process.env.EXTERNAL_IMMICH_URL || process.env.IMMICH_URL) as string,
IMMICH_API_KEY: process.env.IMMICH_API_KEY as string,
DATABASE_URL: (process.env.DATABASE_URL || `postgresql://${process.env.DB_USERNAME}:${process.env.DB_PASSWORD}@${(process.env.DB_HOST || 'immich_postgres')}:${(process.env.DB_PORT || '5432')}/${process.env.DB_DATABASE_NAME}`),
JWT_SECRET: process.env.JWT_SECRET as string,
};
+3 -1
View File
@@ -1,7 +1,9 @@
export const BASE_API_ENDPOINT = '/api';
export const BASE_PROXY_ENDPOINT = BASE_API_ENDPOINT + '/immich-proxy';
export const GET_ME_PATH = BASE_PROXY_ENDPOINT + "/users/me";
export const GET_ME_PATH = BASE_API_ENDPOINT + "/users/me";
export const LOGIN_PATH = BASE_API_ENDPOINT + "/users/login";
export const LIST_PEOPLE_PATH = BASE_API_ENDPOINT + "/people/list";
export const SEARCH_PEOPLE_PATH = BASE_PROXY_ENDPOINT + "/search/person";
+5 -1
View File
@@ -1,6 +1,10 @@
import { GET_ME_PATH } from "@/config/routes";
import { GET_ME_PATH, LOGIN_PATH } from "@/config/routes";
import API from "@/lib/api";
export const getMe = async () => {
return API.get(GET_ME_PATH);
}
export const loginUser = async (email: string, password: string) => {
return API.post(LOGIN_PATH, { email, password });
}
+53 -2
View File
@@ -1,6 +1,13 @@
import { appConfig } from "@/config/app.config"
import { ENV } from "@/config/environment"
import { getCookie } from "@/lib/cookie"
import { decodeJWT } from "@/lib/jwt"
import { NextApiRequest } from "next"
export const getCurrentUserFromAPIKey = () => {
if (!ENV.IMMICH_URL || !ENV.IMMICH_API_KEY) return null;
export const getCurrentUser = () => {
return fetch(ENV.IMMICH_URL + "/api/users/me", {
headers: {
'x-api-key': ENV.IMMICH_API_KEY,
@@ -10,6 +17,50 @@ export const getCurrentUser = () => {
if (res.ok) {
return res.json()
}
throw new Error("Failed to fetch user")
return null
})
}
export const getCurrentUserFromAccessToken = (token: string) => {
return fetch(ENV.IMMICH_URL + "/api/users/me", {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
}).then((res) => {
if (res.ok) {
return res.json()
}
return null
})
}
export const getCurrentUser = async (req: NextApiRequest) => {
const session = getCookie(req, appConfig.sessionCookieName)
if (session) {
const user = await getCurrentUserFromAccessToken(session)
return {
...user,
accessToken: session,
}
}
return getCurrentUserFromAPIKey()
}
export const loginUser = async (email: string, password: string) => {
const res = await fetch(ENV.IMMICH_URL + "/api/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, password }),
})
if (res.ok) {
return res.json()
}
return null
}
+5 -13
View File
@@ -7,23 +7,15 @@ const handleErrors = (error: AxiosError | any) => {
if (error.code === "ERR_BAD_RESPONSE") {
return Promise.reject({
error: "Unable to process this request, please try again later",
message: error.code
message: error.code,
});
}
let errorObject = {}
if (error.response) {
const { status } = error.response
if (status === 422) {
errorObject = {
...error.response.data,
message: 'Unable to process this request, please try again later',
}
} else {
errorObject = {
...error.response.data,
}
}
return Promise.reject(errorObject)
return Promise.reject({
...error.response.data,
status: error.response.status,
})
}
errorObject = {
message: 'Network Error. Please check your connection and try again!',
+30
View File
@@ -0,0 +1,30 @@
import type { CookieSerializeOptions } from 'cookie'
import cookie from 'cookie'
import type { IncomingMessage } from 'http'
import type { NextApiRequest } from 'next'
export const getCookie = (
req: NextApiRequest | IncomingMessage,
name: string
) => {
const cookieData = cookie.parse(req.headers.cookie || '')
if (cookieData && cookieData[name]) {
return cookieData[name]
}
return null
}
export const serializeCookie = (
name: string,
value: string,
options: CookieSerializeOptions = {}
) => {
return cookie.serialize(name, String(value), {
httpOnly: true,
maxAge: 60 * 60 * 24 * 7,
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
...options,
})
}
+16
View File
@@ -0,0 +1,16 @@
import jwt from 'jsonwebtoken'
export const decodeJWT = (token: string, key: string): any => {
try {
const payload = jwt.verify(token, key, {
ignoreExpiration: true
})
return payload
} catch (error) {
return null
}
}
export const encodeJWT = (payload: any, key: string): string => {
return jwt.sign(payload, key)
}
@@ -46,7 +46,7 @@ export default async function handler(
res: NextApiResponse
) {
try {
const currentUser = await getCurrentUser()
const currentUser = await getCurrentUser(req)
const { startDate } = req.query as { startDate: string };
const { rows } = await db.execute(SELECT_ORPHAN_PHOTOS(startDate, currentUser.id));
return res.status(200).json(rows);
@@ -28,7 +28,7 @@ export default async function handler(
res: NextApiResponse
) {
try {
const currentUser = await getCurrentUser()
const currentUser = await getCurrentUser(req)
const { rows } = await db.execute(SELECT_ORPHAN_PHOTOS(currentUser.id));
return res.status(200).json(rows);
} catch (error: any) {
+1 -1
View File
@@ -34,7 +34,7 @@ export default async function handler(
}
try {
const currentUser = await getCurrentUser();
const currentUser = await getCurrentUser(req);
const dataFromDB = await db.select({
value: count(),
label: column,
@@ -15,7 +15,7 @@ export default async function handler(
startDate: string;
};
const currentUser = await getCurrentUser();
const currentUser = await getCurrentUser(req);
if (!startDate) {
return res.status(400).json({
@@ -11,7 +11,7 @@ export default async function handler(
res: NextApiResponse
) {
try {
const currentUser = await getCurrentUser();
const currentUser = await getCurrentUser(req);
const rows = await db
.select({
asset_count: desc(count(assets.id)),
+10 -1
View File
@@ -1,6 +1,7 @@
// pages/api/proxy.js
import { ENV } from '@/config/environment';
import { getCurrentUser } from '@/handlers/serverUtils/user.utils';
import { NextApiRequest, NextApiResponse } from 'next'
export const config = {
@@ -17,13 +18,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const { id } = req.query;
const targetUrl = `${ENV.IMMICH_URL}/api/people/${id}/thumbnail`;
const currentUser = await getCurrentUser(req);
if (!currentUser) {
return res.status(403).json({ message: 'Forbidden' })
}
console.log('currentUser.accessToken:', currentUser)
try {
// Forward the request to the target API
const response = await fetch(targetUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/octet-stream',
'x-api-key': ENV.IMMICH_API_KEY,
Authorization: `Bearer ${currentUser.accessToken}`,
},
})
+1 -1
View File
@@ -34,7 +34,7 @@ export default async function handler(
sortOrder = "desc",
} = req.query as any as IQuery;
const currentUser = await getCurrentUser();
const currentUser = await getCurrentUser(req);
const personRecords = await db
.select()
.from(person)
+1 -1
View File
@@ -44,7 +44,7 @@ export default async function handler(
sortOrder = "desc",
} = req.query as any as IQuery;
const currentUser = await getCurrentUser();
const currentUser = await getCurrentUser(req);
const maximumAssetCount = !maxValue || maxValue <= 0 ? 1000000 : maxValue;
const whereClause = and(
+36
View File
@@ -0,0 +1,36 @@
// pages/api/proxy.js
import { appConfig } from "@/config/app.config";
import { getCurrentUser, loginUser } from "@/handlers/serverUtils/user.utils";
import { serializeCookie } from "@/lib/cookie";
import { NextApiRequest, NextApiResponse } from "next";
export const config = {
api: {
bodyParser: true,
},
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method Not Allowed" });
}
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ message: "Email and password are required" });
}
const user = await loginUser(email, password);
if (user) {
res.setHeader("Set-Cookie", serializeCookie(appConfig.sessionCookieName, user.accessToken));
return res.status(200).json(user);
}
else {
return res.status(403).json({ message: "Invalid User Credentials" });
}
}
+25
View File
@@ -0,0 +1,25 @@
// pages/api/proxy.js
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { NextApiRequest, NextApiResponse } from "next";
export const config = {
api: {
bodyParser: false,
},
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "GET")
return res.status(405).json({ message: "Method Not Allowed" });
const user = getCurrentUser(req);
if (user) return res.status(200).json(user);
return res.status(403).json({ message: "Forbidden" });
}