feat: add design persistence functionality with Auth0 and Supabase integration
- Implemented `useDesignPersistence` composable for managing design records. - Enhanced `useSlipmatDesigner` to support loading designs from JSON. - Created global authentication middleware for route protection. - Added Supabase client plugin for database interactions. - Developed API endpoints for fetching, saving, and retrieving designs. - Introduced utility functions for Auth0 token verification and Supabase client retrieval. - Updated Nuxt configuration to include Auth0 and Supabase environment variables. - Added necessary dependencies for Auth0 and Supabase. - Enhanced TypeScript configuration for improved type support.
This commit is contained in:
47
server/api/designs/[id].get.ts
Normal file
47
server/api/designs/[id].get.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createError } from "h3";
|
||||
|
||||
import { requireAuth0User } from "../../utils/auth0";
|
||||
import { getSupabaseServiceClient } from "../../utils/supabase";
|
||||
|
||||
type DesignRecord = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
template_id: string;
|
||||
preview_url: string | null;
|
||||
preview_path: string | null;
|
||||
design_json: unknown;
|
||||
notes?: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireAuth0User(event);
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const id = event.context.params?.id;
|
||||
|
||||
if (!id) {
|
||||
throw createError({ statusCode: 400, statusMessage: "Design id is required." });
|
||||
}
|
||||
|
||||
const response = await supabase
|
||||
.from("designs")
|
||||
.select("id, user_id, name, template_id, preview_url, preview_path, design_json, notes, created_at, updated_at")
|
||||
.eq("id", id)
|
||||
.eq("user_id", user.sub)
|
||||
.maybeSingle();
|
||||
|
||||
if (response.error) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `Failed to load design: ${response.error.message}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.data) {
|
||||
throw createError({ statusCode: 404, statusMessage: "Design not found." });
|
||||
}
|
||||
|
||||
return response.data as DesignRecord;
|
||||
});
|
||||
39
server/api/designs/index.get.ts
Normal file
39
server/api/designs/index.get.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createError, getQuery } from "h3";
|
||||
|
||||
import { requireAuth0User } from "../../utils/auth0";
|
||||
import { getSupabaseServiceClient } from "../../utils/supabase";
|
||||
|
||||
type DesignSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
template_id: string;
|
||||
preview_url: string | null;
|
||||
preview_path: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireAuth0User(event);
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const query = getQuery(event);
|
||||
|
||||
const limit = query.limit ? Number.parseInt(String(query.limit), 10) : 20;
|
||||
const sanitizedLimit = Number.isFinite(limit) && limit > 0 ? Math.min(limit, 100) : 20;
|
||||
|
||||
const response = await supabase
|
||||
.from("designs")
|
||||
.select("id, name, template_id, preview_url, preview_path, created_at, updated_at")
|
||||
.eq("user_id", user.sub)
|
||||
.order("updated_at", { ascending: false })
|
||||
.limit(sanitizedLimit);
|
||||
|
||||
if (response.error) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `Failed to fetch designs: ${response.error.message}`,
|
||||
});
|
||||
}
|
||||
|
||||
return (response.data as DesignSummary[]) ?? [];
|
||||
});
|
||||
115
server/api/designs/index.post.ts
Normal file
115
server/api/designs/index.post.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createError, readBody } from "h3";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requireAuth0User } from "../../utils/auth0";
|
||||
import { getSupabaseServiceClient } from "../../utils/supabase";
|
||||
import { useRuntimeConfig } from "#imports";
|
||||
|
||||
const requestSchema = z.object({
|
||||
id: z.string().uuid().optional(),
|
||||
name: z.string().min(1).max(120),
|
||||
templateId: z.string().min(1).max(64),
|
||||
previewDataUrl: z.string().regex(/^data:image\/png;base64,/),
|
||||
productionDataUrl: z.string().regex(/^data:image\/png;base64,/).optional(),
|
||||
designJson: z.record(z.any()),
|
||||
notes: z.string().max(640).optional(),
|
||||
});
|
||||
|
||||
type RequestPayload = z.infer<typeof requestSchema>;
|
||||
|
||||
type DesignRecord = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
template_id: string;
|
||||
preview_path: string;
|
||||
preview_url: string | null;
|
||||
design_json: unknown;
|
||||
notes?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
const dataUrlToBuffer = (dataUrl: string): Buffer => {
|
||||
const [, base64Data] = dataUrl.split(",");
|
||||
if (!base64Data) {
|
||||
throw createError({ statusCode: 400, statusMessage: "Invalid image data URI." });
|
||||
}
|
||||
return Buffer.from(base64Data, "base64");
|
||||
};
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireAuth0User(event);
|
||||
const rawBody = await readBody<RequestPayload>(event);
|
||||
const parsed = requestSchema.safeParse(rawBody);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Invalid request body.",
|
||||
data: parsed.error.flatten(),
|
||||
});
|
||||
}
|
||||
|
||||
const body = parsed.data;
|
||||
const config = useRuntimeConfig();
|
||||
const bucket = config.public.supabase?.storageBucket;
|
||||
|
||||
if (!bucket) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Supabase storage bucket is not configured.",
|
||||
});
|
||||
}
|
||||
|
||||
const supabase = getSupabaseServiceClient();
|
||||
|
||||
const designId = body.id ?? randomUUID();
|
||||
const filePath = `previews/${user.sub}/${designId}.png`;
|
||||
|
||||
const uploadResult = await supabase.storage
|
||||
.from(bucket)
|
||||
.upload(filePath, dataUrlToBuffer(body.previewDataUrl), {
|
||||
contentType: "image/png",
|
||||
upsert: true,
|
||||
});
|
||||
|
||||
if (uploadResult.error) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `Failed to upload preview: ${uploadResult.error.message}`,
|
||||
});
|
||||
}
|
||||
|
||||
const { data: publicUrlData } = supabase.storage.from(bucket).getPublicUrl(filePath);
|
||||
const previewUrl = publicUrlData.publicUrl ?? null;
|
||||
|
||||
const record: DesignRecord = {
|
||||
id: designId,
|
||||
user_id: user.sub,
|
||||
name: body.name,
|
||||
template_id: body.templateId,
|
||||
preview_path: filePath,
|
||||
preview_url: previewUrl,
|
||||
design_json: body.designJson,
|
||||
notes: body.notes ?? null,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const upsertResult = await supabase
|
||||
.from("designs")
|
||||
.upsert(record, { onConflict: "id" })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (upsertResult.error) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: `Failed to save design: ${upsertResult.error.message}`,
|
||||
});
|
||||
}
|
||||
|
||||
return upsertResult.data;
|
||||
});
|
||||
82
server/utils/auth0.ts
Normal file
82
server/utils/auth0.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { createError, getHeader, type H3Event } from "h3";
|
||||
import { useRuntimeConfig } from "#imports";
|
||||
import {
|
||||
createRemoteJWKSet,
|
||||
jwtVerify,
|
||||
type JWTPayload,
|
||||
type JWTVerifyOptions,
|
||||
} from "jose";
|
||||
|
||||
const globalKey = Symbol.for("slipmatz.auth0.jwks");
|
||||
|
||||
type GlobalWithJwks = typeof globalThis & {
|
||||
[globalKey]?: ReturnType<typeof createRemoteJWKSet>;
|
||||
};
|
||||
|
||||
const getJwks = (issuer: string) => {
|
||||
const globalScope = globalThis as GlobalWithJwks;
|
||||
if (!globalScope[globalKey]) {
|
||||
globalScope[globalKey] = createRemoteJWKSet(
|
||||
new URL(`${issuer}.well-known/jwks.json`)
|
||||
);
|
||||
}
|
||||
return globalScope[globalKey]!;
|
||||
};
|
||||
|
||||
export type Auth0TokenPayload = JWTPayload & {
|
||||
sub: string;
|
||||
scope?: string;
|
||||
permissions?: string[];
|
||||
};
|
||||
|
||||
export const verifyAccessToken = async (token: string): Promise<Auth0TokenPayload> => {
|
||||
const config = useRuntimeConfig();
|
||||
const domain = config.public.auth0?.domain;
|
||||
|
||||
if (!domain) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Auth0 domain is not configured.",
|
||||
});
|
||||
}
|
||||
|
||||
const issuer = `https://${domain}/`;
|
||||
const jwks = getJwks(issuer);
|
||||
|
||||
const options: JWTVerifyOptions = {
|
||||
issuer,
|
||||
};
|
||||
|
||||
if (config.public.auth0?.audience) {
|
||||
options.audience = config.public.auth0.audience;
|
||||
}
|
||||
|
||||
const { payload } = await jwtVerify(token, jwks, options);
|
||||
if (!payload.sub) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Invalid access token payload.",
|
||||
});
|
||||
}
|
||||
|
||||
return payload as Auth0TokenPayload;
|
||||
};
|
||||
|
||||
export const requireAuth0User = async (event: H3Event): Promise<Auth0TokenPayload> => {
|
||||
const header = getHeader(event, "authorization");
|
||||
if (!header) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Authorization header missing." });
|
||||
}
|
||||
|
||||
const match = header.match(/^Bearer\s+(.+)$/i);
|
||||
if (!match) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Invalid authorization format." });
|
||||
}
|
||||
|
||||
const token = match[1];
|
||||
try {
|
||||
return await verifyAccessToken(token);
|
||||
} catch (error) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Access token verification failed." });
|
||||
}
|
||||
};
|
||||
34
server/utils/supabase.ts
Normal file
34
server/utils/supabase.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
import { createError } from "h3";
|
||||
import { useRuntimeConfig } from "#imports";
|
||||
|
||||
const globalKey = Symbol.for("slipmatz.supabase.serviceClient");
|
||||
|
||||
type GlobalWithSupabase = typeof globalThis & {
|
||||
[globalKey]?: SupabaseClient;
|
||||
};
|
||||
|
||||
export const getSupabaseServiceClient = (): SupabaseClient => {
|
||||
const config = useRuntimeConfig();
|
||||
const supabaseUrl = config.public.supabase?.url;
|
||||
const serviceRoleKey = config.supabase?.serviceRoleKey;
|
||||
|
||||
if (!supabaseUrl || !serviceRoleKey) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Supabase environment variables are not configured.",
|
||||
});
|
||||
}
|
||||
|
||||
const scope = globalThis as GlobalWithSupabase;
|
||||
if (!scope[globalKey]) {
|
||||
scope[globalKey] = createClient(supabaseUrl, serviceRoleKey, {
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
persistSession: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return scope[globalKey]!;
|
||||
};
|
||||
Reference in New Issue
Block a user