feat: setup auth flow to work in parallel with api key
Signed-off-by: Varun Raj <mailme@varunraj.in>
This commit is contained in:
1 parent
2f19b5908d
commit
5e71e1eeeb
23 files changed
+296
-30
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -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!',
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
Reference in new issue
Block a user