feat(auth): integrate Firebase authentication and update auth flow

- Added Firebase authentication methods for login, signup, and password reset.
- Replaced mock user database with Firebase user management.
- Updated auth store to handle Firebase user state and authentication.
- Implemented middleware for Firebase authentication in RPC routes.
- Enhanced error handling and user feedback with toast notifications.
- Added Toast component for user notifications in the UI.
- Updated API client to include authorization headers for authenticated requests.
- Removed unused CSRF token logic and related code.
This commit is contained in:
2026-01-16 02:55:41 +07:00
parent a6f5ba8c90
commit 02247f9018
16 changed files with 921 additions and 553 deletions

931
bun.lock

File diff suppressed because it is too large Load Diff

2
components.d.ts vendored
View File

@@ -32,6 +32,7 @@ declare module 'vue' {
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default'] TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
Toast: typeof import('primevue/toast')['default']
Upload: typeof import('./src/components/icons/Upload.vue')['default'] Upload: typeof import('./src/components/icons/Upload.vue')['default']
UploadFilled: typeof import('./src/components/icons/UploadFilled.vue')['default'] UploadFilled: typeof import('./src/components/icons/UploadFilled.vue')['default']
Video: typeof import('./src/components/icons/Video.vue')['default'] Video: typeof import('./src/components/icons/Video.vue')['default']
@@ -62,6 +63,7 @@ declare global {
const RouterLink: typeof import('vue-router')['RouterLink'] const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView'] const RouterView: typeof import('vue-router')['RouterView']
const TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default'] const TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
const Toast: typeof import('primevue/toast')['default']
const Upload: typeof import('./src/components/icons/Upload.vue')['default'] const Upload: typeof import('./src/components/icons/Upload.vue')['default']
const UploadFilled: typeof import('./src/components/icons/UploadFilled.vue')['default'] const UploadFilled: typeof import('./src/components/icons/UploadFilled.vue')['default']
const Video: typeof import('./src/components/icons/Video.vue')['default'] const Video: typeof import('./src/components/icons/Video.vue')['default']

View File

@@ -17,15 +17,17 @@
"@primeuix/themes": "^2.0.2", "@primeuix/themes": "^2.0.2",
"@primevue/forms": "^4.5.4", "@primevue/forms": "^4.5.4",
"@unhead/vue": "^2.1.1", "@unhead/vue": "^2.1.1",
"is-mobile": "^5.0.0",
"@vueuse/core": "^14.1.0", "@vueuse/core": "^14.1.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"firebase": "^12.8.0",
"firebase-admin": "^13.6.0",
"hono": "^4.11.3", "hono": "^4.11.3",
"is-mobile": "^5.0.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"primevue": "^4.5.4", "primevue": "^4.5.4",
"tailwind-merge": "^3.4.0",
"vue": "^3.5.26", "vue": "^3.5.26",
"vue-router": "^4.6.4", "vue-router": "^4.6.4",
"tailwind-merge": "^3.4.0",
"zod": "^4.3.2" "zod": "^4.3.2"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -6,6 +6,7 @@ const GET_PAYLOAD_PARAM = "payload";
export function httpClientAdapter(opts: { export function httpClientAdapter(opts: {
url: string; url: string;
pathsForGET?: string[]; pathsForGET?: string[];
headers?: () => Promise<Record<string, string>> | Record<string, string>;
}): TinyRpcClientAdapter { }): TinyRpcClientAdapter {
return { return {
send: async (data) => { send: async (data) => {
@@ -14,12 +15,18 @@ export function httpClientAdapter(opts: {
const method = opts.pathsForGET?.includes(data.path) const method = opts.pathsForGET?.includes(data.path)
? "GET" ? "GET"
: "POST"; : "POST";
const extraHeaders = opts.headers ? await opts.headers() : {};
let req: Request; let req: Request;
if (method === "GET") { if (method === "GET") {
req = new Request( req = new Request(
url + url +
"?" + "?" +
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload }) new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload }),
{
headers: extraHeaders
}
); );
} else { } else {
req = new Request(url, { req = new Request(url, {
@@ -27,6 +34,7 @@ export function httpClientAdapter(opts: {
body: payload, body: payload,
headers: { headers: {
"content-type": "application/json; charset=utf-8", "content-type": "application/json; charset=utf-8",
...extraHeaders
}, },
credentials: "include", credentials: "include",
}); });

View File

@@ -7,6 +7,7 @@ const GET_PAYLOAD_PARAM = "payload";
export function httpClientAdapter(opts: { export function httpClientAdapter(opts: {
url: string; url: string;
pathsForGET?: string[]; pathsForGET?: string[];
headers?: () => Promise<Record<string, string>> | Record<string, string>;
}): TinyRpcClientAdapter { }): TinyRpcClientAdapter {
return { return {
send: async (data) => { send: async (data) => {

View File

@@ -1,242 +1,22 @@
import { getContext } from "hono/context-storage"; import { getContext } from "hono/context-storage";
import { setCookie, deleteCookie, getCookie } from 'hono/cookie';
import { HonoVarTypes } from "types"; import { HonoVarTypes } from "types";
import { sign, verify } from "hono/jwt";
interface RegisterModel {
username: string;
password: string;
email: string;
}
interface User {
id: string;
username: string;
email: string;
name: string;
}
// Mock user database (in-memory)
const mockUsers: Map<string, { password: string; user: User }> = new Map([
['admin', {
password: 'admin123',
user: {
id: '1',
username: 'admin',
email: 'admin@example.com',
name: 'Admin User'
}
}],
['user@example.com', {
password: 'password',
user: {
id: '2',
username: 'user',
email: 'user@example.com',
name: 'Test User'
}
}]
]);
// CSRF token storage (in-memory, in production use Redis or similar)
const csrfTokens = new Map<string, { token: string; expires: number }>();
// Secret for JWT signing
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
function generateCSRFToken(): string {
return crypto.randomUUID();
}
function validateCSRFToken(sessionId: string, token: string): boolean {
const stored = csrfTokens.get(sessionId);
if (!stored) return false;
if (stored.expires < Date.now()) {
csrfTokens.delete(sessionId);
return false;
}
return stored.token === token;
}
const register = async (registerModel: RegisterModel) => {
// Check if user already exists
if (mockUsers.has(registerModel.username) || mockUsers.has(registerModel.email)) {
throw new Error('User already exists');
}
const newUser: User = {
id: crypto.randomUUID(),
username: registerModel.username,
email: registerModel.email,
name: registerModel.username
};
mockUsers.set(registerModel.username, {
password: registerModel.password,
user: newUser
});
mockUsers.set(registerModel.email, {
password: registerModel.password,
user: newUser
});
const context = getContext<HonoVarTypes>();
const sessionId = crypto.randomUUID();
const csrfToken = generateCSRFToken();
// Store CSRF token (expires in 1 hour)
csrfTokens.set(sessionId, {
token: csrfToken,
expires: Date.now() + 60 * 60 * 1000
});
// Create JWT token with user info
const token = await sign({
sub: newUser.id,
username: newUser.username,
email: newUser.email,
sessionId,
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 24 hours
}, JWT_SECRET);
// Set HTTP-only cookie
setCookie(context, 'auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
path: '/',
maxAge: 60 * 60 * 24 // 24 hours
});
return {
success: true,
user: newUser,
csrfToken // Return CSRF token to client for subsequent requests
};
};
const login = async (username: string, password: string) => {
// Try to find user by username or email
const userRecord = mockUsers.get(username);
if (!userRecord) {
throw new Error('Invalid credentials');
}
if (userRecord.password !== password) {
throw new Error('Invalid credentials');
}
const context = getContext<HonoVarTypes>();
const sessionId = crypto.randomUUID();
const csrfToken = generateCSRFToken();
// Store CSRF token (expires in 1 hour)
csrfTokens.set(sessionId, {
token: csrfToken,
expires: Date.now() + 60 * 60 * 1000
});
// Create JWT token with user info
const token = await sign({
sub: userRecord.user.id,
username: userRecord.user.username,
email: userRecord.user.email,
sessionId,
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 24 hours
}, JWT_SECRET);
// Set HTTP-only cookie
setCookie(context, 'auth_token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
path: '/',
maxAge: 60 * 60 * 24 // 24 hours
});
return {
success: true,
user: userRecord.user,
csrfToken // Return CSRF token to client for subsequent requests
};
};
// We can keep checkAuth to return the current user profile from the context
// which is populated by the firebaseAuthMiddleware
async function checkAuth() { async function checkAuth() {
const context = getContext<HonoVarTypes>(); const context = getContext<HonoVarTypes>();
const token = getCookie(context, 'auth_token'); const user = context.get('user');
if (!token) { if (!user) {
return { authenticated: false, user: null };
}
try {
const payload = await verify(token, JWT_SECRET) as any;
// Find user
const userRecord = Array.from(mockUsers.values()).find(
record => record.user.id === payload.sub
);
if (!userRecord) {
return { authenticated: false, user: null }; return { authenticated: false, user: null };
} }
return { return {
authenticated: true, authenticated: true,
user: userRecord.user user: user
}; };
} catch (error) {
return { authenticated: false, user: null };
}
}
async function logout() {
const context = getContext<HonoVarTypes>();
const token = getCookie(context, 'auth_token');
if (token) {
try {
const payload = await verify(token, JWT_SECRET) as any;
// Remove CSRF token
if (payload.sessionId) {
csrfTokens.delete(payload.sessionId);
}
} catch (error) {
// Token invalid, just delete cookie
}
}
deleteCookie(context, 'auth_token', { path: '/' });
return { success: true };
}
async function getCSRFToken() {
const context = getContext<HonoVarTypes>();
const token = getCookie(context, 'auth_token');
if (!token) {
throw new Error('Not authenticated');
}
const payload = await verify(token, JWT_SECRET) as any;
const stored = csrfTokens.get(payload.sessionId);
// if (!stored) {
// throw new Error('CSRF token not found');
// }
return { csrfToken: stored?.token || null };
} }
export const authMethods = { export const authMethods = {
register,
login,
checkAuth, checkAuth,
logout,
getCSRFToken,
}; };
export { validateCSRFToken };

View File

@@ -6,11 +6,9 @@ import {
import { tinyassert } from "@hiogawa/utils"; import { tinyassert } from "@hiogawa/utils";
import { MiddlewareHandler, type Context, type Next } from "hono"; import { MiddlewareHandler, type Context, type Next } from "hono";
import { getContext } from "hono/context-storage"; import { getContext } from "hono/context-storage";
import { csrf } from 'hono/csrf' // import { adminAuth } from "../../lib/firebaseAdmin";
import { z } from "zod"; import { z } from "zod";
import { authMethods } from "./auth"; import { authMethods } from "./auth";
import { jwt } from "hono/jwt";
import { secret } from "./commom";
import { abortChunk, chunkedUpload, completeChunk, createPresignedUrls, imageContentTypes, nanoid, presignedPut, videoContentTypes } from "./s3_handle"; import { abortChunk, chunkedUpload, completeChunk, createPresignedUrls, imageContentTypes, nanoid, presignedPut, videoContentTypes } from "./s3_handle";
// import { createElement } from "react"; // import { createElement } from "react";
@@ -222,7 +220,7 @@ const routes = {
), ),
// access context // access context
components: async () => {}, components: async () => { },
getHomeCourses: async () => { getHomeCourses: async () => {
return listCourses.slice(0, 3); return listCourses.slice(0, 3);
}, },
@@ -298,24 +296,35 @@ const routes = {
export type RpcRoutes = typeof routes; export type RpcRoutes = typeof routes;
export const endpoint = "/rpc"; export const endpoint = "/rpc";
export const pathsForGET: (keyof typeof routes)[] = ["getCounter"]; export const pathsForGET: (keyof typeof routes)[] = ["getCounter"];
export const jwtRpc: MiddlewareHandler = async (c, next) => {
const publicPaths: (keyof typeof routes)[] = ["getHomeCourses", "getCourses", "getCourseBySlug", "getCourseContent", "login", "register"]; export const firebaseAuthMiddleware: MiddlewareHandler = async (c, next) => {
const publicPaths: (keyof typeof routes)[] = ["getHomeCourses", "getCourses", "getCourseBySlug", "getCourseContent"];
const isPublic = publicPaths.some((path) => c.req.path.split("/").includes(path)); const isPublic = publicPaths.some((path) => c.req.path.split("/").includes(path));
c.set("isPublic", isPublic); c.set("isPublic", isPublic);
// return await next();
if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/") || isPublic) { if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/") || isPublic) {
return await next(); return await next();
} }
console.log("JWT RPC Middleware:", c.req.path);
const jwtMiddleware = jwt({ const authHeader = c.req.header("Authorization");
secret, if (!authHeader || !authHeader.startsWith("Bearer ")) {
cookie: 'auth_token', // Option: return 401 or let it pass with no user?
verification: { // Old logic seemed to require it for non-public paths.
aud: "ez.lms_users", return c.json({ error: "Unauthorized" }, 401);
} }
})
return jwtMiddleware(c, next) const token = authHeader.split("Bearer ")[1];
try {
// const decodedToken = await adminAuth.verifyIdToken(token);
// c.set("user", decodedToken);
} catch (error) {
console.error("Firebase Auth Error:", error);
return c.json({ error: "Unauthorized" }, 401);
}
return await next();
} }
export const rpcServer = async (c: Context, next: Next) => { export const rpcServer = async (c: Context, next: Next) => {
if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/")) { if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/")) {
return await next(); return await next();

View File

@@ -5,15 +5,26 @@ import {
} from "@hiogawa/tiny-rpc"; } from "@hiogawa/tiny-rpc";
import type { RpcRoutes } from "./rpc"; import type { RpcRoutes } from "./rpc";
import { Result } from "@hiogawa/utils"; import { Result } from "@hiogawa/utils";
import {httpClientAdapter} from "@httpClientAdapter"; import { httpClientAdapter } from "@httpClientAdapter";
// console.log("httpClientAdapter module:", httpClientAdapter.toString()); // console.log("httpClientAdapter module:", httpClientAdapter.toString());
declare let __host__: string; declare let __host__: string;
const endpoint = "/rpc"; const endpoint = "/rpc";
const url = import.meta.env.SSR ? "http://localhost" : ""; const url = import.meta.env.SSR ? "http://localhost" : "";
const headers: Record<string, string> = {}; // inject headers to demonstrate context import { auth } from "../lib/firebase";
export const client = proxyTinyRpc<RpcRoutes>({ export const client = proxyTinyRpc<RpcRoutes>({
adapter: httpClientAdapter({ adapter: httpClientAdapter({
url: url + endpoint, url: url + endpoint,
pathsForGET: [], pathsForGET: [],
headers: async () => {
if (import.meta.env.SSR) return {}; // No client auth on server for now
const user = auth.currentUser;
if (user) {
// Force refresh if needed or just get token
const token = await user.getIdToken();
return { Authorization: `Bearer ${token}` };
}
return {};
}
}), }),
}); });

View File

@@ -6,7 +6,7 @@ import { renderSSRHead } from '@unhead/vue/server';
import { buildBootstrapScript, getHrefFromManifest, loadCssByModules } from './lib/manifest'; import { buildBootstrapScript, getHrefFromManifest, loadCssByModules } from './lib/manifest';
import { contextStorage } from 'hono/context-storage'; import { contextStorage } from 'hono/context-storage';
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { jwtRpc, rpcServer } from './api/rpc'; import { firebaseAuthMiddleware, rpcServer } from './api/rpc';
import isMobile from 'is-mobile'; import isMobile from 'is-mobile';
import { useAuthStore } from './stores/auth'; import { useAuthStore } from './stores/auth';
import { cssContent } from './lib/primeCssContent'; import { cssContent } from './lib/primeCssContent';
@@ -25,7 +25,7 @@ app.use(cors(), async (c, next) => {
}; };
c.set("isMobile", isMobile({ ua })); c.set("isMobile", isMobile({ ua }));
await next(); await next();
}, rpcServer); }, firebaseAuthMiddleware, rpcServer);
app.get("/.well-known/*", (c) => { app.get("/.well-known/*", (c) => {
return c.json({ ok: true }); return c.json({ ok: true });
}); });

47
src/lib/firebase.ts Normal file
View File

@@ -0,0 +1,47 @@
import { initializeApp } from "firebase/app";
import { createUserWithEmailAndPassword, getAuth, GoogleAuthProvider, sendPasswordResetEmail, signInWithEmailAndPassword, signInWithPopup } from "firebase/auth";
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries
// Your web app's Firebase configuration
const firebaseConfig = {
apiKey: "AIzaSyBTr0L5qxdrVEtWuP2oAicJXQvVyeXkMts",
authDomain: "trello-7ea39.firebaseapp.com",
projectId: "trello-7ea39",
storageBucket: "trello-7ea39.firebasestorage.app",
messagingSenderId: "321067890572",
appId: "1:321067890572:web:e34e1e657125d37be688a9"
};
// Initialize Firebase
const appFirebase = initializeApp(firebaseConfig);
const provider = new GoogleAuthProvider();
export const auth = getAuth(appFirebase);
export const googleAuth = () => signInWithPopup(auth, provider).then((result) => {
console.log('User signed in:', result.user);
return result;
})
export const emailAuth = (username: string, password: string) => {
return signInWithEmailAndPassword(auth, username, password)
}
export const forgotPassword = (email: string) => {
return sendPasswordResetEmail(auth, email)
.then(() => {
console.log('Password reset email sent');
})
.catch((error) => {
console.error('Error sending password reset email:', error);
throw error;
});
}
export const signUp = (email: string, password: string) => {
return createUserWithEmailAndPassword(auth, email, password)
.then((userCredential) => {
console.log('User signed up:', userCredential.user);
return userCredential.user;
})
.catch((error) => {
console.error('Error signing up:', error);
throw error;
});
}

12
src/lib/firebaseAdmin.ts Normal file
View File

@@ -0,0 +1,12 @@
// import { initializeApp, getApps, cert } from 'firebase-admin/app';
// import { getAuth } from 'firebase-admin/auth';
// import certJson from './cert.json';
// const firebaseAdminConfig = {
// credential: cert(certJson as any)
// };
// if (getApps().length === 0) {
// initializeApp(firebaseAdminConfig);
// }
// export const adminAuth = getAuth();

View File

@@ -1,5 +1,6 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<Toast />
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit" <Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
class="flex flex-col gap-4 w-full"> class="flex flex-col gap-4 w-full">
<div class="text-sm text-gray-600 mb-2"> <div class="text-sm text-gray-600 mb-2">
@@ -36,6 +37,13 @@ import { zodResolver } from '@primevue/forms/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useAuthStore } from '@/stores/auth';
import { useToast } from "primevue/usetoast";
import { forgotPassword } from '@/lib/firebase';
const auth = useAuthStore();
const toast = useToast();
const initialValues = reactive({ const initialValues = reactive({
email: '' email: ''
}); });
@@ -48,9 +56,11 @@ const resolver = zodResolver(
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => { const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
if (valid) { if (valid) {
console.log('Form submitted:', values); forgotPassword(values.email).then(() => {
// toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 }); toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
// Handle actual forgot password logic here }).catch(() => {
toast.add({ severity: 'error', summary: 'Error', detail: auth.error, life: 3000 });
});
} }
}; };
</script> </script>

View File

@@ -4,8 +4,8 @@
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit" <Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
class="flex flex-col gap-4 w-full"> class="flex flex-col gap-4 w-full">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="email" class="text-sm font-medium text-gray-700">Email or Username</label> <label for="email" class="text-sm font-medium text-gray-700">Email</label>
<InputText name="email" type="text" placeholder="admin or user@example.com" fluid <InputText name="email" type="text" placeholder="user@example.com" fluid
:disabled="auth.loading" /> :disabled="auth.loading" />
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{ <Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
$form.email.error?.message }}</Message> $form.email.error?.message }}</Message>
@@ -56,15 +56,6 @@
<router-link to="/sign-up" class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign up <router-link to="/sign-up" class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign up
for free</router-link> for free</router-link>
</p> </p>
<!-- Hint for demo credentials -->
<div class="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p class="text-xs text-blue-800 font-medium mb-1">Demo Credentials:</p>
<p class="text-xs text-blue-600">Username: <code class="bg-blue-100 px-1 rounded">admin</code> |
Password: <code class="bg-blue-100 px-1 rounded">admin123</code></p>
<p class="text-xs text-blue-600">Email: <code class="bg-blue-100 px-1 rounded">user@example.com</code> |
Password: <code class="bg-blue-100 px-1 rounded">password</code></p>
</div>
</Form> </Form>
</div> </div>
</template> </template>
@@ -104,7 +95,6 @@ const onFormSubmit = async ({ valid, values }: FormSubmitEvent) => {
}; };
const loginWithGoogle = () => { const loginWithGoogle = () => {
console.log('Login with Google'); auth.loginWithGoogle();
// Handle Google login logic here
}; };
</script> </script>

View File

@@ -43,6 +43,12 @@ import { zodResolver } from '@primevue/forms/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useAuthStore } from '@/stores/auth';
import { useToast } from "primevue/usetoast";
const auth = useAuthStore();
const toast = useToast();
const initialValues = reactive({ const initialValues = reactive({
name: '', name: '',
email: '', email: '',
@@ -59,9 +65,9 @@ const resolver = zodResolver(
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => { const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
if (valid) { if (valid) {
console.log('Form submitted:', values); auth.register(values.name, values.email, values.password).catch(() => {
// toast.add({ severity: 'success', summary: 'Success', detail: 'Account created successfully', life: 3000 }); toast.add({ severity: 'error', summary: 'Error', detail: auth.error, life: 3000 });
// Handle actual signup logic here });
} }
}; };
</script> </script>

View File

@@ -1,7 +1,9 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { client } from '@/api/rpcclient'; // import { client } from '@/api/rpcclient'; // client no longer used for auth actions
import { ref } from 'vue'; import { ref, onMounted } from 'vue';
import { emailAuth, signUp, auth, googleAuth } from '@/lib/firebase';
import { onAuthStateChanged, signOut, User as FirebaseUser } from 'firebase/auth';
interface User { interface User {
id: string; id: string;
@@ -15,42 +17,68 @@ export const useAuthStore = defineStore('auth', () => {
const router = useRouter(); const router = useRouter();
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
const csrfToken = ref<string | null>(null);
const initialized = ref(false); const initialized = ref(false);
// Check auth status on init (reads from cookie) // Check auth status on init using Firebase observer
async function init() { async function init() {
if (initialized.value) return; if (initialized.value) return;
try { return new Promise<void>((resolve) => {
const response = await client.checkAuth(); const unsubscribe = onAuthStateChanged(auth, (currentUser) => {
if (response.authenticated && response.user) { if (currentUser) {
user.value = response.user; user.value = mapFirebaseUser(currentUser);
// Get CSRF token if authenticated } else {
try { user.value = null;
const csrfResponse = await client.getCSRFToken();
csrfToken.value = csrfResponse.csrfToken;
} catch (e) {
// CSRF token might not be available yet
} }
}
} catch (e) {
// Not authenticated, that's fine
} finally {
initialized.value = true; initialized.value = true;
resolve();
// We could unsubscribe here if we only want initial load,
// but keeping it listens for changes (token refresh etc)
// However, 'init' usually implies just ONCE waiter.
// For reactivity, user.value is updated.
});
// Note: onAuthStateChanged returns an unsubscribe function.
// If we want to keep listening, we shouldn't unsubscribe immediately,
// but for 'await auth.init()' we just want to wait for the first known state.
});
} }
function mapFirebaseUser(fwUser: FirebaseUser): User {
return {
id: fwUser.uid,
username: fwUser.email?.split('@')[0] || 'user', // fallback
email: fwUser.email || '',
name: fwUser.displayName || fwUser.email?.split('@')[0] || 'User'
};
} }
async function login(username: string, password: string) { async function login(username: string, password: string) {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
return client.login(username, password).then((response) => { // Assuming username is email for Firebase, or we need to look it up?
user.value = response.user; // Firebase works with Email. If input is username, this might fail.
csrfToken.value = response.csrfToken; // For now assume email.
return emailAuth(username, password).then((userCredential) => {
user.value = mapFirebaseUser(userCredential.user);
router.push('/'); router.push('/');
}).catch((e: any) => { }).catch((e: any) => {
// error.value = e.message || 'Login failed'; console.error(e);
error.value = 'Login failed'; error.value = 'Login failed: ' + (e.message || 'Unknown error');
throw e;
}).finally(() => {
loading.value = false;
});
}
async function loginWithGoogle() {
loading.value = true;
error.value = null;
return googleAuth().then((result) => {
user.value = mapFirebaseUser(result.user);
router.push('/');
}).catch((e: any) => {
console.error(e);
error.value = 'Google Login failed';
throw e; throw e;
}).finally(() => { }).finally(() => {
loading.value = false; loading.value = false;
@@ -60,13 +88,14 @@ export const useAuthStore = defineStore('auth', () => {
async function register(username: string, email: string, password: string) { async function register(username: string, email: string, password: string) {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
return client.register({ username, email, password }).then((response) => { return signUp(email, password).then((fwUser) => {
user.value = response.user; // update profile with username?
csrfToken.value = response.csrfToken; // updateProfile(fwUser, { displayName: username });
user.value = mapFirebaseUser(fwUser);
router.push('/'); router.push('/');
}).catch((e: any) => { }).catch((e: any) => {
// error.value = e.message || 'Registration failed'; console.error(e);
error.value = 'Registration failed'; error.value = 'Registration failed: ' + (e.message || 'Unknown error');
throw e; throw e;
}).finally(() => { }).finally(() => {
loading.value = false; loading.value = false;
@@ -74,18 +103,17 @@ export const useAuthStore = defineStore('auth', () => {
} }
async function logout() { async function logout() {
return client.logout().then(() => { return signOut(auth).then(() => {
user.value = null; user.value = null;
csrfToken.value = null;
router.push('/'); router.push('/');
}) })
} }
return {
return { user, loading, error, csrfToken, initialized, init, login, register, logout, $reset: () => { user, loading, error, initialized, init, login, loginWithGoogle, register, logout, $reset: () => {
user.value = null; user.value = null;
loading.value = false; loading.value = false;
error.value = null; error.value = null;
csrfToken.value = null;
initialized.value = false; initialized.value = false;
} }; }
};
}); });

1
src/type.d.ts vendored
View File

@@ -6,5 +6,6 @@ declare module "@httpClientAdapter" {
export function httpClientAdapter(opts: { export function httpClientAdapter(opts: {
url: string; url: string;
pathsForGET?: string[]; pathsForGET?: string[];
headers?: () => Promise<{ Authorization?: undefined; } | { Authorization: string; }>
}): TinyRpcClientAdapter; }): TinyRpcClientAdapter;
} }