diff --git a/.gitignore b/.gitignore index a547bf3..7035d44 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ node_modules dist dist-ssr *.local +tmp + +.env # Editor directories and files .vscode/* diff --git a/.roo/rules/rules.md b/.roo/rules/rules.md new file mode 100644 index 0000000..d99b9e1 --- /dev/null +++ b/.roo/rules/rules.md @@ -0,0 +1 @@ +ALWAYS READ `docs/llm/instructions.md` BEFORE STARTING ANY TASK. diff --git a/.vscode/extensions.json b/.vscode/extensions.json index a7cea0b..7f2d863 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,6 @@ { - "recommendations": ["Vue.volar"] + "recommendations": [ + "Vue.volar", + "bradlc.vscode-tailwindcss" + ] } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9e477c7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,100 @@ +# AGENTS.md + +## Commands + +- **Build**: `bun run build` (this generates the `dist/` folder) + +## Working Guidelines + +### Before Starting Tasks +- Read the `README.md` to get project context and progress +- When needing current date/time, run `date +%Y-%m-%d@%H:%M` for YYYY-MM-DD@HH:MM format (24-hour, no AM/PM) +- Git operations (commits, branches, merges, rebases) are NOT part of agent tasks + +### Working with Plans +- Save plans in `docs/llm/plans/YYYY/MM/` directory structure +- Name files as `DD-task-name.md` (day + dash + lowercase task with dashes) +- Include implementation details: files to modify/create, code samples for new functions +- If applicable, suggest existing npm packages that can help with the implementation during plan creation; if multiple packages exist, list pros/cons and recommend one +- Never include git operations, changelogs, PR descriptions, version bumps, or release notes in plans +- Never worry about backwards compatibility if the version is pre-1.0.0 +- If plan involves using a component from Shadcn that is not already created, include the command to generate it using the Shadcn CLI +- Do not code immediately after writing plans - wait for user review/approval + +### Implementing Plans +- Only start implementation after explicit user command +- During implementation, STOP and inform user if deviating from the plan in any way +- Never implement anything not explicitly mentioned in the plan +- After implementation is fully finished and all tests pass, run the linter command and fix any issues + + +## Project Structure + +- This is a Bun project. Never use `npm`, `node`, `typescript`, or `tsc` commands. Always use `bun` commands. +- This is a Vue project using Typescript natively (via Bun). Typescript transpilation is not required. + +- Entry point is `src/main.ts`. +- Main app component is `src/App.vue`. +- Views live in `src/views/`. +- Components live in `src/components/`. + +- Stores live in `src/stores/`. + - The project uses Pinia for state management. + - The Auth store stores user session state from the GraphQL backend. + +- The router (Vue Router) is configured in `src/lib/router.ts`. + - When adding child routes, do NOT add the leading slash (`/`) to the path. + - Example: use `path: 'settings'` instead of `path: '/settings'` for a child route. + +- The lib folder (`src/lib/`) also contains utility functions and modules. + +- The project uses Shadcn-Vue for component generation. Generated components live in `src/components/ui/`. + - When installing new Shadcn-Vue components, use the `bunx --bun shadcn-vue@latest add ` command. + - After installing a Shadcn-Vue component, do not change the generated code. If changes are necessary, document them in the plan. + +- Other components live in `src/components/` (not in `ui/`). + +- The project has an `@` alias pointing to `src/` for easier imports. When importing from `src/`, use `@/` instead of relative paths. + +- Styles live in `src/style.css`. However that file is mostly for TailwindCSS imports and global styles. + - For global styles, use App.vue's ` +``` + +### src/plugins/my-plugin.ts + +```typescript +import type { App } from 'vue' +import type { AppOptions } from '../types' + +export default { + install(app: App, options?: AppOptions) { + // Add global properties + app.config.globalProperties.$api = { + baseUrl: options?.apiBaseUrl || 'https://api.example.com', + get: async (endpoint: string) => { + const response = await fetch(`${options?.apiBaseUrl}${endpoint}`) + return response.json() + } + } + + // Add custom directives + app.directive('focus', { + mounted(el) { + el.focus() + } + }) + + // Register global components if needed + // app.component('GlobalButton', GlobalButton) + } +} +``` + +### src/composables/useAppConfig.ts + +```typescript +import { inject } from 'vue' +import type { AppOptions } from '../types' + +export function useAppConfig() { + const config = inject('appConfig', {}) + + return { + config, + theme: config.theme || 'light', + apiBaseUrl: config.apiBaseUrl || '', + isAnalyticsEnabled: config.enableAnalytics || false + } +} +``` + +### src/styles/main.css + +```css +/* Your global styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; +} + +/* Add more global styles */ +``` + +### tsconfig.json + +```json +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} +``` + +### Publishing Your Library + +```bash +# No build step needed! +bun publish +``` + +--- + +## Consumer's Project Setup + +### Project Structure + +``` +consumer-project/ +├── src/ +│ └── main.ts # Entry point - minimal code! +├── index.html +├── package.json +├── vite.config.ts +└── tsconfig.json +``` + +### package.json + +```json +{ + "name": "consumer-app", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0", + "your-library-name": "^1.0.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.0.0", + "vite": "^5.0.0", + "vue-tsc": "^2.0.0" + } +} +``` + +### vite.config.ts + +```typescript +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// Standard Vite config - compiles your library's source code automatically +export default defineConfig({ + plugins: [vue()] +}) +``` + +### src/main.ts + +```typescript +import { createApp } from 'your-library-name' +import 'your-library-name/style.css' + +// That's all the consumer needs to write! +const app = createApp({ + theme: 'dark', + apiBaseUrl: 'https://api.example.com', + enableAnalytics: true, + customData: { + appName: 'My Awesome App', + version: '1.0.0' + } +}) + +app.mount('#app') +``` + +### index.html + +```html + + + + + + + My App + + +
+ + + +``` + +### Consumer's Build Commands + +```bash +# Install dependencies +bun install + +# Development server +bun run dev + +# Production build +bun run build + +# Preview production build +bun run preview +``` + +--- + +## How It Works + +1. **Your library** ships uncompiled `.vue` and `.ts` files +2. **Consumer installs** your library via `bun install your-library-name` +3. **Consumer's Vite** processes and compiles: + - Your library's Vue components (`.vue` files) + - Your library's TypeScript (`.ts` files) + - Consumer's own code +4. **Everything is bundled** together into the final application + +--- + +## Benefits of This Approach + +- **No build step for you** - just publish source code +- **Better tree-shaking** - consumer's bundler removes unused code +- **Easier debugging** - source maps point to actual source code +- **Full optimization control** - consumer's build tool handles everything +- **Simple workflow** - fewer moving parts + +--- + +## Advanced: Allow Consumer Customization + +If you want to let consumers override parts of your app: + +### src/index.ts (Enhanced) + +```typescript +import { createApp as vueCreateApp, App, Component } from 'vue' +import PrebuiltApp from './PrebuiltApp.vue' +import MyPlugin from './plugins/my-plugin' +import type { AppOptions } from './types' + +export interface ExtendedAppOptions extends AppOptions { + // Allow custom root component + rootComponent?: Component + // Allow additional plugins + plugins?: any[] + // Allow additional global components + components?: Record +} + +export function createApp(options?: ExtendedAppOptions): App { + // Use custom root component if provided, otherwise use default + const rootComponent = options?.rootComponent || PrebuiltApp + const app = vueCreateApp(rootComponent) + + app.use(MyPlugin, options) + app.provide('appConfig', options || {}) + + // Install additional plugins if provided + options?.plugins?.forEach(plugin => { + app.use(plugin) + }) + + // Register additional components if provided + if (options?.components) { + Object.entries(options.components).forEach(([name, component]) => { + app.component(name, component) + }) + } + + return app +} +``` + +### Consumer Usage (Advanced) + +```typescript +import { createApp } from 'your-library-name' +import CustomComponent from './CustomComponent.vue' +import myPlugin from './my-plugin' + +const app = createApp({ + theme: 'dark', + apiBaseUrl: 'https://api.example.com', + plugins: [myPlugin], + components: { + 'custom-component': CustomComponent + } +}) + +app.mount('#app') +``` + +--- + +## Summary + +- **Your library**: Publish source code directly (no compilation) +- **Consumer**: Use standard Vite + Vue setup with Bun +- **Result**: Consumer gets your complete app with minimal configuration diff --git a/docs/llm/plans/2025/11/16-update-app-vue.md b/docs/llm/plans/2025/11/16-update-app-vue.md new file mode 100644 index 0000000..8fc7901 --- /dev/null +++ b/docs/llm/plans/2025/11/16-update-app-vue.md @@ -0,0 +1,79 @@ +# Plan: update-app-vue + +Summary +- Implement a minimal base layout in [`src/App.vue`](src/App.vue:1) with a top navbar, main content area, and footer using Tailwind utilities and Vue ` + + +``` + +Verification +- Visual check in browser using `bun run dev`. +- Confirm full-bleed backgrounds for navbar/footer and constrained inner content at large widths. +- Confirm main content is vertically and horizontally centered. + +Next steps after approval +- Switch to code mode and implement the layout in [`src/App.vue`](src/App.vue:1). +- Update the todo list to mark the plan complete and start implementation. +- Run the dev server and iterate as needed. + +Saved plan path: [`docs/llm/plans/2025/11/16-update-app-vue.md`](docs/llm/plans/2025/11/16-update-app-vue.md:1) \ No newline at end of file diff --git a/docs/llm/plans/2025/11/19-update-loginview-session.md b/docs/llm/plans/2025/11/19-update-loginview-session.md new file mode 100644 index 0000000..d3f4dcf --- /dev/null +++ b/docs/llm/plans/2025/11/19-update-loginview-session.md @@ -0,0 +1,121 @@ +# Plan: Update [`src/views/LoginView.vue`](src/views/LoginView.vue:1) to use session store + +Summary + +- Update the login view to authenticate using the Pinia session store. +- Prevent default form submission and use the store's sessionAuthenticate(). +- Disable UI and show spinner while sessionLoading is true; display sessionLastError on failure. +- After successful authentication, redirect to the route named sign-in-success (use the route name, not the path). Read route names from [`src/lib/router.ts`](src/lib/router.ts:1). + +Files to modify + +- [`src/views/LoginView.vue`](src/views/LoginView.vue:1) +- [`src/stores/session.ts`](src/stores/session.ts:1) +- [`src/lib/router.ts`](src/lib/router.ts:1) (read for route names; contains name: 'sign-in-success') + +Decisions + +- Adapt the existing markup in [`src/views/LoginView.vue`](src/views/LoginView.vue:1); do not replace the file. +- Expose sessionLastError from [`src/stores/session.ts`](src/stores/session.ts:1). +- Redirect after successful authentication using a named route push (router.push({ name: 'sign-in-success' })). + +Implementation steps + +1) Store change + +- Add sessionLastError to the returned object so views can read it. +- Snippet: + +```ts +// src/stores/session.ts — return section + return { + sessionLoading, + sessionData, + sessionLastError, + sessionAuthenticate, + sessionLogout, + } +``` + +2) LoginView changes (adapt existing file) + +- Import ref from 'vue', Spinner component, and useSessionStore from the session store. +- Use useRouter() from 'vue-router' (or import the exported router) to perform a named push on success. +- Add username and password refs and bind them to the Input components with v-model. +- Add an async onSubmit() that calls sessionStore.sessionAuthenticate and checks its boolean return value. +- Add @submit.prevent to the
so it does not submit like a normal HTML form. +- Disable inputs and buttons while sessionStore.sessionLoading is true using :disabled. +- Show Spinner in the button and change its label to "Logging in…" when sessionStore.sessionLoading is true. +- Display sessionStore.sessionLastError via FieldError under the button when present. + +3) Post-success redirect (new required step) + +- Read the route definitions in [`src/lib/router.ts`](src/lib/router.ts:1) — the file defines a route with name 'sign-in-success'. +- When sessionAuthenticate returns true, redirect to that route using the route name: router.push({ name: 'sign-in-success' }). +- Use useRouter() inside the component (recommended) or import the exported router from [`src/lib/router.ts`](src/lib/router.ts:1). + +Example onSubmit snippet + +```ts +import { useRouter } from 'vue-router'; +const router = useRouter(); +async function onSubmit () { + const ok = await sessionStore.sessionAuthenticate(username.value, password.value); + if (ok) { + router.push({ name: 'sign-in-success' }); + } +} +``` + +Example component fragments (keep existing structure; integrate these fragments) + +```ts + +``` + +```vue + +``` + +Testing + +- Correct credentials (user/pass): verify spinner appears, authentication completes, and the app navigates to the named route sign-in-success. +- Incorrect credentials: verify error message from sessionLastError appears and inputs/buttons re-enable. +- Confirm the form does not cause a full-page reload on submit. + +Next steps + +- After you approve this updated plan I will implement: + - the small store export change, + - the edits to [`src/views/LoginView.vue`](src/views/LoginView.vue:1) (bindings, submit handler, spinner, error, redirect), + - update the todo list statuses accordingly. \ No newline at end of file diff --git a/docs/llm/plans/2025/11/19-update-registerview.md b/docs/llm/plans/2025/11/19-update-registerview.md new file mode 100644 index 0000000..6116843 --- /dev/null +++ b/docs/llm/plans/2025/11/19-update-registerview.md @@ -0,0 +1,163 @@ +# Plan: Update RegisterView.vue to use auth store + +Objective + +- Update [`src/views/RegisterView.vue`](src/views/RegisterView.vue:1) to use the auth store API and follow patterns from [`src/views/LoginView.vue`](src/views/LoginView.vue:1) and [`src/stores/auth.ts`](src/stores/auth.ts:1). + +Scope + +- Modify only [`src/views/RegisterView.vue`](src/views/RegisterView.vue:1). + +Requirements + +1. Make the form password-manager-friendly — prevent default submit and submit programmatically (like LoginView). +2. Use authRegister() from the store to create the user. +3. Add a field error element under the button showing authRegisterLastError from the store. +4. Add keydown.enter handlers on all input fields (matching LoginView). +5. Add :disabled bindings on all inputs and buttons using authLoading. +6. Add the spinner and change the button description while authLoading is true. + +Implementation steps + +1. Import the store and components: `useAuthStore`, `Spinner`, plus `ref` and `useRouter`. +2. Add reactive refs: `username`, `password`, `passwordConfirm`. +3. Add an async `onSubmit()` that calls `authStore.authRegister(username.value, password.value, passwordConfirm.value)`. On success call `router.replace({ name: 'home' })`. On failure do NOT clear password fields; keep user input intact and display authRegisterLastError. +4. Update the form to use `@submit.prevent` and change the submit control to `type="button"` with `@click="onSubmit"` (same pattern as LoginView). +5. Add `@keydown.enter.prevent="onSubmit"` to username, password, and confirm fields. +6. Add `:disabled="authStore.authLoading"` to all inputs and the submit button. +7. Replace the button label with a `` and "Creating account…" while `authStore.authLoading` is true. +8. Render `` beneath the button bound to `authStore.authRegisterLastError`. + +Code sample (full revised component) + +```vue + + + +``` + +Notes and rationale + +- Preventing native submit and using programmatic submission mirrors [`src/views/LoginView.vue`](src/views/LoginView.vue:1) and improves password manager behavior. +- Clearing password fields on failure prevents password managers from offering to save incorrect credentials. +- `:disabled` bindings and spinner maintain consistent UX with LoginView. + +Testing + +- Manual test: autofill with a password manager, press Enter in fields, verify spinner and disabled states, confirm FieldError shows when passwords mismatch. + +Next steps + +- After plan approval, implement the changes in [`src/views/RegisterView.vue`](src/views/RegisterView.vue:1) and run the tests. + +End. \ No newline at end of file diff --git a/docs/llm/plans/2025/11/20-login-success-view.md b/docs/llm/plans/2025/11/20-login-success-view.md new file mode 100644 index 0000000..fe30053 --- /dev/null +++ b/docs/llm/plans/2025/11/20-login-success-view.md @@ -0,0 +1,68 @@ +# Login success view plan + +Goal +Implement a non-functional layout for the login-success route that displays "You are logged in" inside a card with two buttons: Return to Site and Account Settings. + +Constraints +- Follow project Vue conventions: ` + + +``` + +Steps +1. Update [`src/views/LoginSuccessView.vue`](src/views/LoginSuccessView.vue:1) with the proposed component. +2. Run the dev server (bun run dev) and visually verify the layout. +3. If routes are missing, leave as non-functional placeholders; update later as needed. + +Summary of changes +- One file modified: [`src/views/LoginSuccessView.vue`](src/views/LoginSuccessView.vue:1) + +Approval +- If this plan is approved I will implement the change in [`src/views/LoginSuccessView.vue`](src/views/LoginSuccessView.vue:1). \ No newline at end of file diff --git a/docs/llm/plans/2025/11/24-graphql-client-analysis.md b/docs/llm/plans/2025/11/24-graphql-client-analysis.md new file mode 100644 index 0000000..6e557fb --- /dev/null +++ b/docs/llm/plans/2025/11/24-graphql-client-analysis.md @@ -0,0 +1,686 @@ +# GraphQL Client Analysis and Recommendation + +## Date: 2025-11-24@13:08 + +## Overview +Analysis of GraphQL client options for Vue.js project with aggressive retry requirements during backend deployments. Backend is Rust async-graphql (non-Apollo). + +## Requirements +- Retry only on HTTP 502 status code (indicates app deployment/unavailability) +- Once 502 is received, retry once per second for 60 seconds +- Fail request if still receiving 502 after 60 seconds +- No retry for other errors (network, 4xx, 5xx except 502) +- Vue.js integration +- Compatible with non-Apollo GraphQL backends +- Production-ready reliability + +## Options Analyzed + +### 1. urql (Recommended) + +**Pros:** +- Exchange-based architecture with `@urql/exchange-retry` +- Highly customizable retry logic with fine-grained control +- Native `@urql/vue` package with excellent Vue 3 Composition API support +- Reasonable bundle size (~12KB core + exchanges) +- Backend-agnostic design +- Good TypeScript support +- Active development (8.9k GitHub stars) + +**Cons:** +- Exchange architecture has learning curve +- Smaller community than Apollo + +**Retry Configuration:** +```typescript +import { createClient, cacheExchange, fetchExchange } from '@urql/vue' +import { retryExchange } from '@urql/exchange-retry' + +const client = createClient({ + url: 'https://your-graphql-endpoint.com/graphql', + exchanges: [ + cacheExchange, + retryExchange({ + initialDelayMs: 1000, // Retry once per second + maxDelayMs: 1000, // Keep consistent 1-second intervals + maxNumberAttempts: 60, // Retry for 60 seconds total + retryIf: error => { + // Only retry on HTTP 502 (Bad Gateway) - deployment indicator + return error.response && error.response.status === 502 + } + }), + fetchExchange + ] +}) +``` + +### 2. Vue Apollo + +**Pros:** +- Largest GraphQL client community +- Official `@vue/apollo-composable` with excellent Vue integration +- Comprehensive feature set +- Extensive documentation and resources + +**Cons:** +- Larger bundle size (~50KB+) +- More complex link chain architecture +- Steeper learning curve +- Overkill for simple use cases + +**Retry Configuration:** +```typescript +import { RetryLink } from '@apollo/client/link/retry' + +const retryLink = new RetryLink({ + delay: { + initial: 300, + max: 10000, + jitter: true + }, + attempts: { + max: 15, + retryIf: (error, _operation) => { + return !!error && error.networkError !== undefined + } + } +}) +``` + +### 3. Graffle (formerly graphql-request) + +**Pros:** +- Smallest bundle size (~8KB) +- Simple API design +- Extension-based architecture +- Good TypeScript support + +**Cons:** +- Less mature Vue integration +- Smaller community (6.1k GitHub stars) +- Fewer production battle scars +- Limited ecosystem + +## Decision: urql + +After careful analysis, **urql is selected** as the GraphQL client for this project due to: + +1. **Precise retry control** - Exchange-based architecture allows exact 502-specific retry logic +2. **Simple retry configuration** - Clear, debuggable retry mechanism without complex link chains +3. **Excellent Vue integration** - Native `@urql/vue` with Vue 3 Composition API support +4. **Reasonable bundle size** - ~12KB core + exchanges (vs Apollo's ~50KB) +5. **Backend-agnostic** - Perfect compatibility with Rust async-graphql +6. **Production-ready** - Sufficiently mature with active development (8.9k GitHub stars) + +The specific retry requirements (502-only, 1-second intervals for 60 seconds) are perfectly suited to urql's `retryExchange` configuration. + +## Implementation Plan + +### Files to Create/Modify: + +1. **`src/lib/utils/getAuthApiUrl.ts`** - Helper function to assemble GraphQL URL from environment variables +2. **`src/lib/graphql-auth-client.ts`** - GraphQL authentication client configuration +3. **`src/lib/auth-wrapper.ts`** - Wrapper with internal methods (_authLogin, _authRegister, etc.) that hide GraphQL queries +4. **`src/stores/auth.ts`** - Update existing auth store to call wrapper methods and manage state +5. **`src/stores/site.ts`** - New site store with similar pattern +6. **`src/main.ts`** - Integrate client with Vue app +7. **`package.json`** - Add urql dependencies + +### Dependencies to Add: +```bash +bun add @urql/vue @urql/exchange-retry +``` + +### Environment Variables to Configure: +```bash +# .env file +VITE_DPS_DOMAIN=dps.localhost +VITE_DPS_API_SUBDOMAIN=api +VITE_DPS_AUTH_API_SUBDOMAIN=auth +VITE_DPS_AUTH_API_PORT=4000 +VITE_DPS_AUTH_API_PROTOCOL=http +``` + +### Client Configuration: +```typescript +// src/lib/utils/getAuthApiUrl.ts +/** + * Assembles the GraphQL authentication API URL from environment variables + * Based on docs/drafts/graphql-url-assembly.md + */ +export function getAuthApiUrl(): string { + const protocol = import.meta.env.VITE_DPS_AUTH_API_PROTOCOL || 'http' + const domain = import.meta.env.VITE_DPS_DOMAIN || 'localhost' + const apiSubdomain = import.meta.env.VITE_DPS_API_SUBDOMAIN || 'api' + const authSubdomain = import.meta.env.VITE_DPS_AUTH_API_SUBDOMAIN || 'auth' + const port = import.meta.env.VITE_DPS_AUTH_API_PORT + + // Build subdomain structure: auth.api.dps.localhost + const subdomain = `${authSubdomain}.${apiSubdomain}.${domain}` + + // Assemble URL with optional port + const baseUrl = `${protocol}://${subdomain}` + const portSuffix = port ? `:${port}` : '' + + return `${baseUrl}${portSuffix}/graphql` +} +``` + +```typescript +// src/lib/graphql-auth-client.ts +import { createClient, cacheExchange, fetchExchange } from '@urql/vue' +import { retryExchange } from '@urql/exchange-retry' +import { getAuthApiUrl } from './utils/getAuthApiUrl' + +export const graphqlAuthClient = createClient({ + url: getAuthApiUrl(), + exchanges: [ + cacheExchange, + retryExchange({ + initialDelayMs: 1000, // Retry once per second + maxDelayMs: 1000, // Keep consistent 1-second intervals + maxNumberAttempts: 60, // Retry for 60 seconds total + retryIf: error => { + // Only retry on HTTP 502 (Bad Gateway) - deployment indicator + return error.response && error.response.status === 502 + } + }), + fetchExchange + ], + fetchOptions: { + headers: { + 'Content-Type': 'application/json', + } + } +}) +``` + +### App Integration: +```typescript +// src/main.ts +import { createApp } from 'vue' +import { provideClient } from '@urql/vue' +import { graphqlAuthClient } from '@/lib/graphql-auth-client' +import App from './App.vue' +import router from '@/lib/router' + +const app = createApp(App) +app.use(router) +provideClient(graphqlAuthClient) +app.mount('#app') +``` + +### Usage in Components: +```typescript +// Example usage in LoginView.vue with auth store + +``` + +```typescript +// Example usage with site store + +``` + +### Auth Store Integration: +```typescript +// src/stores/auth.ts - Updated methods +import { _authLogin, _authRegister, _authLogout, _authMe, _authChangePassword } from '@/lib/auth-wrapper' + +// Replace placeholder authLogin method +async function login(username: string, password: string) { + authLoading.value = true + authLoginLastError.value = null + + try { + const result = await _authLogin(username, password) + authSessionData.value = { + username: result.username, + user_id: result.user_id, + token: result.token + } + return true + } catch (error) { + authLoginLastError.value = error.message + return false + } finally { + authLoading.value = false + } +} + +// Replace placeholder authRegister method +async function register(username: string, password: string, passwordConfirm: string) { + authLoading.value = true + authRegisterLastError.value = null + + try { + const result = await _authRegister(username, password, passwordConfirm) + authSessionData.value = { + username: result.username, + user_id: result.user_id, + uuid: result.uuid + } + return true + } catch (error) { + authRegisterLastError.value = error.message + return false + } finally { + authLoading.value = false + } +} + +// Replace placeholder authLogout method +async function logout() { + authLoading.value = true + + try { + await _authLogout() + authSessionData.value = null + } catch (error) { + console.error('Logout error:', error) + } finally { + authLoading.value = false + } +} + +// Add new me method +async function me() { + try { + const user = await _authMe() + authSessionData.value = { + username: user.username, + user_id: user.user_id, + uuid: user.uuid + } + return user + } catch (error) { + authSessionData.value = null + throw error + } +} + +// Add changePassword method +async function changePassword(currentPassword: string, newPassword: string) { + authLoading.value = true + + try { + await _authChangePassword(currentPassword, newPassword) + return true + } catch (error) { + authLoginLastError.value = error.message + return false + } finally { + authLoading.value = false + } +} +``` + +### Site Store: +```typescript +// src/stores/site.ts - New store +import { defineStore } from "pinia"; +import { ref } from "vue"; +import { _sites, _addSite, _updateSite, _removeSite } from '@/lib/auth-wrapper'; + +export const useSiteStore = defineStore('site', () => { + const loading = ref(false); + const sites = ref([]); + const lastError = ref(null); + + async function fetch() { + loading.value = true; + lastError.value = null; + + try { + sites.value = await _sites(); + } catch (error) { + lastError.value = error.message; + } finally { + loading.value = false; + } + } + + async function add(slug: string, subdomain?: string, port?: number, protocol?: string, metadataJson?: string) { + loading.value = true; + lastError.value = null; + + try { + const newSite = await _addSite(slug, subdomain, port, protocol, metadataJson); + sites.value.push(newSite); + return newSite; + } catch (error) { + lastError.value = error.message; + throw error; + } finally { + loading.value = false; + } + } + + async function update(id: number, slug?: string, subdomain?: string, port?: number, protocol?: string, metadataJson?: string) { + loading.value = true; + lastError.value = null; + + try { + const updatedSite = await _updateSite(id, slug, subdomain, port, protocol, metadataJson); + const index = sites.value.findIndex(site => site.id === id); + if (index !== -1) { + sites.value[index] = updatedSite; + } + return updatedSite; + } catch (error) { + lastError.value = error.message; + throw error; + } finally { + loading.value = false; + } + } + + async function remove(siteId: number) { + loading.value = true; + lastError.value = null; + + try { + const removedSite = await _removeSite(siteId); + sites.value = sites.value.filter(site => site.id !== siteId); + return removedSite; + } catch (error) { + lastError.value = error.message; + throw error; + } finally { + loading.value = false; + } + } + + return { + loading, + sites, + lastError, + fetch, + add, + update, + remove, + } +}) +``` + +### Auth Wrapper Implementation: +```typescript +// src/lib/auth-wrapper.ts +import { client } from '@urql/vue' + +// GraphQL query/mutation constants +const GET_SERVER_TIMESTAMP = ` + query GetServerTimestamp { + getServerTimestamp + } +` + +const AUTH_ME = ` + query AuthMe { + authMe { + user_id + uuid + username + role_id + created_ts + updated_ts + session_iat + session_exp + } + } +` + +const SITES = ` + query Sites { + sites { + id + slug + subdomain + port + protocol + } + } +` + +const AUTH_REGISTER = ` + mutation AuthRegister($username: String!, $password: String!, $passwordConfirmation: String!) { + authRegister(username: $username, password: $password, passwordConfirmation: $passwordConfirmation) { + user_id + uuid + username + role_id + created_ts + updated_ts + message + } + } +` + +const AUTH_LOGIN = ` + mutation AuthLogin($username: String!, $password: String!) { + authLogin(username: $username, password: $password) { + token + user_id + username + message + } + } +` + +const AUTH_LOGOUT = ` + mutation AuthLogout { + authLogout { + message + } + } +` + +const AUTH_CHANGE_PASSWORD = ` + mutation AuthChangePassword($currentPassword: String!, $newPassword: String!) { + authChangePassword(currentPassword: $currentPassword, newPassword: $newPassword) { + message + } + } +` + +const ADD_SITE = ` + mutation AddSite($slug: String!, $subdomain: String, $port: Int, $protocol: String, $metadataJson: String) { + addSite(slug: $slug, subdomain: $subdomain, port: $port, protocol: $protocol, metadataJson: $metadataJson) { + id + slug + subdomain + port + protocol + metadataJson + created_ts + updated_ts + } + } +` + +const UPDATE_SITE = ` + mutation UpdateSite($id: Int!, $slug: String, $subdomain: String, $port: Int, $protocol: String, $metadataJson: String) { + updateSite(id: $id, slug: $slug, subdomain: $subdomain, port: $port, protocol: $protocol, metadataJson: $metadataJson) { + id + slug + subdomain + port + protocol + metadataJson + created_ts + updated_ts + } + } +` + +const REMOVE_SITE = ` + mutation RemoveSite($site_id: Int!) { + removeSite(site_id: $site_id) { + id + slug + subdomain + port + protocol + metadataJson + created_ts + updated_ts + } + } +` + +// Type definitions based on schema +export interface AuthMeResponse { + user_id: number + uuid: string + username: string + role_id: number + created_ts: number + updated_ts: number + session_iat: number + session_exp: number +} + +export interface AuthRegisterResponse { + user_id: number + uuid: string + username: string + role_id: number + created_ts: number + updated_ts: number + message: string +} + +export interface AuthLoginResponse { + token: string + user_id: number + username: string + message: string +} + +export interface Site { + id: number + slug: string + subdomain: string + port: number + protocol: string + metadataJson?: string + created_ts: number + updated_ts: number +} + +// Internal wrapper functions (underscore prefix to avoid naming conflicts with store methods) +export async function _getServerTimestamp(): Promise { + const result = await client.query(GET_SERVER_TIMESTAMP).toPromise() + if (result.error) throw result.error + return result.data.getServerTimestamp +} + +export async function _authMe(): Promise { + const result = await client.query(AUTH_ME).toPromise() + if (result.error) throw result.error + return result.data.authMe +} + +export async function _sites(): Promise { + const result = await client.query(SITES).toPromise() + if (result.error) throw result.error + return result.data.sites +} + +export async function _authRegister(username: string, password: string, passwordConfirmation: string): Promise { + const result = await client.mutation(AUTH_REGISTER, { username, password, passwordConfirmation }).toPromise() + if (result.error) throw result.error + return result.data.authRegister +} + +export async function _authLogin(username: string, password: string): Promise { + const result = await client.mutation(AUTH_LOGIN, { username, password }).toPromise() + if (result.error) throw result.error + return result.data.authLogin +} + +export async function _authLogout(): Promise<{ message: string }> { + const result = await client.mutation(AUTH_LOGOUT).toPromise() + if (result.error) throw result.error + return result.data.authLogout +} + +export async function _authChangePassword(currentPassword: string, newPassword: string): Promise<{ message: string }> { + const result = await client.mutation(AUTH_CHANGE_PASSWORD, { currentPassword, newPassword }).toPromise() + if (result.error) throw result.error + return result.data.authChangePassword +} + +export async function _addSite(slug: string, subdomain?: string, port?: number, protocol?: string, metadataJson?: string): Promise { + const result = await client.mutation(ADD_SITE, { slug, subdomain, port, protocol, metadataJson }).toPromise() + if (result.error) throw result.error + return result.data.addSite +} + +export async function _updateSite(id: number, slug?: string, subdomain?: string, port?: number, protocol?: string, metadataJson?: string): Promise { + const result = await client.mutation(UPDATE_SITE, { id, slug, subdomain, port, protocol, metadataJson }).toPromise() + if (result.error) throw result.error + return result.data.updateSite +} + +export async function _removeSite(siteId: number): Promise { + const result = await client.mutation(REMOVE_SITE, { site_id: siteId }).toPromise() + if (result.error) throw result.error + return result.data.removeSite +} +``` + +```typescript + +``` + +## Next Steps + +1. Review and approve this plan +2. Create `getAuthApiUrl` helper function with environment variable assembly +3. Implement GraphQL authentication client configuration using the URL helper +4. Create auth wrapper with internal methods (_authLogin, _authRegister, etc.) that hide GraphQL queries +5. Update existing auth store to call wrapper methods and manage state automatically +6. Create new site store with similar pattern +7. Update existing components to use store methods instead of mock data + +This setup will provide robust GraphQL communication with precise 502-specific retry behavior for deployment resilience, flexible URL configuration, and clean store APIs that automatically manage state while hiding GraphQL complexity from components. \ No newline at end of file diff --git a/docs/llm/plans/2025/11/24-implement-auth-route-guards.md b/docs/llm/plans/2025/11/24-implement-auth-route-guards.md new file mode 100644 index 0000000..303c32e --- /dev/null +++ b/docs/llm/plans/2025/11/24-implement-auth-route-guards.md @@ -0,0 +1,101 @@ +# 24-implement-auth-route-guards.md + +## Overview +Implement route guards to protect authenticated routes using Vue Router's navigation guards and the existing authStore. The home route should only be accessible to authenticated users, redirecting unauthenticated users to the login route. + +## Implementation Details + +### Files to Modify + +#### 1. `src/lib/router.ts` +- Add meta field to routes that require authentication +- Implement global navigation guard using `router.beforeEach` +- Import and use the authStore to check authentication status + +#### 2. `src/stores/auth.ts` (if needed) +- Add a computed getter for checking if user is authenticated +- This will provide a clean way to check authentication status in the route guard + +### Implementation Steps + +#### Step 1: Add Auth Getter to Auth Store +Add a computed property `isAuthenticated` to the auth store: +```typescript +const isAuthenticated = computed(() => sessionData.value !== null) +``` + +#### Step 2: Update Router Configuration +Modify the routes array to include meta fields: +```typescript +const routes = [ + { + path: '/', + name: 'home', + component: HomeView, + meta: { requiresAuth: true } + }, + { path: '/login', name: 'login', component: LoginView }, + { + path: '/login-success', + name: 'login-success', + component: LoginSuccessView, + meta: { requiresAuth: true } + }, + { path: '/register', name: 'register', component: RegisterView }, +] +``` + +#### Step 3: Implement Navigation Guard +Add a `router.beforeEach` navigation guard: +```typescript +router.beforeEach((to, from, next) => { + const authStore = useAuthStore() + + if (to.meta.requiresAuth && !authStore.isAuthenticated) { + // Redirect to login page with return URL + next({ + name: 'login', + query: { redirect: to.fullPath } + }) + } else { + next() + } +}) +``` + +#### Step 4: Handle Redirect After Login +Update the login success logic in `LoginView.vue` to handle the redirect query parameter: +```typescript +async function onSubmit () { + const ok = await authStore.login(username.value, password.value); + if (ok) { + const redirectPath = router.currentRoute.value.query.redirect as string || 'login-success' + router.replace(redirectPath.startsWith('/') ? redirectPath : { name: redirectPath }) + } else { + password.value = ''; + } +} +``` + +### Technical Considerations + +#### Route Guard Logic +- The guard will check `to.meta.requiresAuth` before allowing access +- Unauthenticated users accessing protected routes will be redirected to login +- The original destination is preserved in the query parameter for post-login redirect + +#### Auth State Management +- The auth store's `sessionData` serves as the source of truth for authentication +- A computed getter provides a clean boolean check for authentication status +- No additional state management is required beyond the existing auth store + +#### Redirect Flow +- Users are redirected to login with the intended destination in the query +- After successful login, users are redirected to their original destination +- Falls back to 'login-success' if no redirect parameter exists + +### Benefits +- Clean separation of concerns between routing and authentication logic +- Reusable pattern for future protected routes +- Preserves user experience by maintaining intended navigation flow +- Uses existing auth store without requiring additional state management \ No newline at end of file diff --git a/docs/llm/plans/2025/11/26-fix-auth-store-null-user-handling.md b/docs/llm/plans/2025/11/26-fix-auth-store-null-user-handling.md new file mode 100644 index 0000000..9ac6d8c --- /dev/null +++ b/docs/llm/plans/2025/11/26-fix-auth-store-null-user-handling.md @@ -0,0 +1,112 @@ +# Fix Auth Store _authMe Null User Handling + +## Date +2025-11-26@20:06 + +## Problem Analysis + +The auth store has an issue in the `me()` function (line 76-90) where `_authMe()` can return a null user when the user is not authenticated, but the current code assumes the user object will always be present. + +### Current Issue +In `src/stores/auth.ts:76-90`, the `me()` function handles the `_authMe()` response incorrectly: + +```typescript +async function me (): Promise { + sessionInfoPromise.value = _authMe() + .then(user => { + sessionData.value = { + username: user.username, // This will fail if user is null + userId: user.userId, // This will fail if user is null + uuid: user.uuid // This will fail if user is null + }; + }) + .catch(error => { + sessionData.value = null; + throw error; + }); + + return sessionInfoPromise.value; +} +``` + +The issue is that when `_authMe()` is called without a valid session, it likely returns `null` or throws an error, but the current code only handles errors in the `.catch()` block. If `null` is returned (not an error), the `.then()` block will try to access properties on `null`, causing a runtime error. + +## Root Cause + +Based on the GraphQL schema documentation, `authMe` requires "Valid session cookie". When no valid session exists: +1. The GraphQL query likely returns `null` for the `authMe` field +2. The `_authMe()` wrapper returns `result.data.authMe` which would be `null` +3. The current code doesn't handle the `null` case properly + +## Solution Plan + +### Files to Modify +- `src/stores/auth.ts` - Fix the `me()` function to handle null user response + +### Implementation Details + +1. **Update the `me()` function** to properly handle null user response: + - Check if the returned user is null before accessing its properties + - Set `sessionData.value = null` when user is null (not authenticated) + - Only set session data when user is not null + +2. **Update the `_authMe()` wrapper function** in `src/lib/auth-wrapper.ts`: + - Modify the return type to allow null: `Promise` + - Handle the case where `result.data.authMe` is null + +### Code Changes + +#### 1. Update auth-wrapper.ts (_authMe function) + +```typescript +export async function _authMe(): Promise { + const result = await client.query(AUTH_ME, {}).toPromise() + if (result.error) throw result.error + return result.data.authMe // This can be null when not authenticated +} +``` + +#### 2. Update auth.ts (me function) + +```typescript +async function me (): Promise { + sessionInfoPromise.value = _authMe() + .then(user => { + if (user) { + sessionData.value = { + username: user.username, + userId: user.userId, + uuid: user.uuid + }; + } else { + sessionData.value = null; + } + }) + .catch(error => { + sessionData.value = null; + throw error; + }); + + return sessionInfoPromise.value; +} +``` + +### Testing Strategy + +1. **Test unauthenticated state**: Verify that calling `me()` when not logged in sets `sessionData.value = null` without throwing errors +2. **Test authenticated state**: Verify that calling `me()` when logged in properly sets the session data +3. **Test error handling**: Verify that GraphQL errors are still properly caught and handled + +### Benefits + +- Fixes runtime errors when checking authentication status +- Properly handles the valid use case of users not being logged in +- Maintains existing error handling for actual GraphQL errors +- No breaking changes to the public API of the auth store + +## Implementation Notes + +- This is a bug fix, not a feature change +- The solution maintains backward compatibility +- No new dependencies are required +- The fix aligns with the expected behavior described in the GraphQL schema documentation \ No newline at end of file diff --git a/docs/llm/plans/2025/11/26-fix-cookie-handling-in-graphql-client.md b/docs/llm/plans/2025/11/26-fix-cookie-handling-in-graphql-client.md new file mode 100644 index 0000000..13ed670 --- /dev/null +++ b/docs/llm/plans/2025/11/26-fix-cookie-handling-in-graphql-client.md @@ -0,0 +1,122 @@ +# 26-fix-cookie-handling-in-graphql-client + +## Problem Analysis + +After examining the authentication setup and response headers, and researching cookie behavior extensively, I've identified the actual issues preventing cookie storage: + +### Current Issues + +1. **Missing Credentials Configuration**: The URQL GraphQL client is not configured to include credentials in requests, which is required for cookies to be sent and received. **This is the primary issue.** + +2. **Secure Flag on HTTP**: The cookie has `Secure` flag but the app is running on HTTP (not HTTPS), which prevents cookie storage in most browsers. + +3. **Localhost Domain Quirks**: The response cookie has `Domain=.dps.localhost` but some browsers have issues with localhost domain handling. Research shows that for localhost development, it's often better to omit the domain attribute entirely. + +4. **Cookie Path is Correct**: The cookie has `Path=/api` which is correct for `/api/graphql` requests. Cookie path matching works as expected - `/api/graphql` starts with `/api`. + +5. **Port Does NOT Affect Domain Matching**: Research confirms that ports are ignored in cookie domain matching. The port `:5000` is not causing the domain mismatch issue. + +6. **CORS Configuration**: While the server allows `*` origin, proper credential handling requires specific origin configuration when using credentials. + +## Solution Plan + +### 1. Fix GraphQL Client Credentials Configuration (Primary Fix) + +**Files to modify**: `src/lib/graphql-auth-client.ts`, `src/lib/auth-wrapper.ts` + +Add `credentials: 'include'` to both GraphQL client configurations to ensure cookies are sent and received properly. This is the most critical fix. + +### 2. Fix Secure Flag Conflict + +**Backend changes needed** (coordinate with backend team): +- Remove `Secure` flag for HTTP development environments, OR +- Configure the frontend to run on HTTPS in development + +### 3. Optimize Localhost Domain Handling + +**Backend changes needed** (coordinate with backend team): +- For localhost development, consider omitting the `Domain` attribute entirely and letting the browser use the default +- If domain must be set, ensure it follows proper localhost conventions + +### 4. Verify CORS Configuration + +**Backend changes needed** (coordinate with backend team): +- When using `credentials: 'include'`, the server must respond with `Access-Control-Allow-Credentials: true` +- The `Access-Control-Allow-Origin` cannot be `*` when credentials are used - must specify the exact origin + +### 5. Add Cookie Validation + +**Files to modify**: `src/stores/auth.ts` + +Add validation to ensure cookies are being properly stored after login, and provide better error handling when cookies fail to save. + +### 6. Test Cookie Persistence + +**Files to modify**: Add test utilities + +Create a utility to check if cookies are being properly stored and accessible across requests. + +## Implementation Details + +### Step 1: Update GraphQL Client Configuration (Critical) + +```typescript +// In both graphql-auth-client.ts and auth-wrapper.ts +fetchOptions: { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + } +} +``` + +### Step 2: Backend CORS and Cookie Configuration + +Request backend team to update: +- CORS headers: `Access-Control-Allow-Credentials: true` and specific origin instead of `*` +- Cookie flags: Remove `Secure` flag for HTTP environments +- Domain handling: Consider omitting `Domain` attribute for localhost + +### Step 3: Environment Configuration (Optional) + +Add HTTPS development server configuration if you want to keep the `Secure` flag. + +### Step 4: Add Cookie Validation + +```typescript +// In auth store +async function validateCookieStorage(): Promise { + try { + await _authMe(); + return true; + } catch (error) { + console.warn('Cookie validation failed - cookies may not be stored'); + return false; + } +} +``` + +### Step 5: Update Login Flow + +Modify the login method to validate cookie storage after successful authentication. + +## Expected Outcome + +After implementing these changes: +- Cookies will be properly stored by the browser after successful login +- Subsequent GraphQL requests will include the authentication cookie +- The user session will persist across page refreshes +- Cross-subdomain authentication will work correctly + +## Key Insights + +- **Port does not affect cookie domain matching** - the `:5000` port is not the issue +- **Missing credentials configuration is the primary blocker** - without `credentials: 'include'`, cookies won't be sent/received +- **Secure flag conflicts with HTTP** - this is a common development environment issue +- **Localhost domain handling has quirks** - sometimes omitting domain entirely works better + +## Dependencies + +- Backend team cooperation to adjust CORS and cookie configuration +- HTTPS development environment or backend cookie flag adjustments +- Testing across different browsers to ensure consistent cookie behavior \ No newline at end of file diff --git a/docs/llm/plans/2025/11/26-update-getAuthApiUrl-function.md b/docs/llm/plans/2025/11/26-update-getAuthApiUrl-function.md new file mode 100644 index 0000000..8c5d703 --- /dev/null +++ b/docs/llm/plans/2025/11/26-update-getAuthApiUrl-function.md @@ -0,0 +1,92 @@ +# Plan: Update getAuthApiUrl Function + +## Overview +Update the `getAuthApiUrl` function to remove the deleted `VITE_DPS_API_SUBDOMAIN` variable and incorporate the new `VITE_DPS_API_PATH` variable. + +## Current Function Location +`src/lib/utils/getAuthApiUrl.ts` + +## Changes Required + +### 1. Remove VITE_DPS_API_SUBDOMAIN +- Delete any code that references `VITE_DPS_API_SUBDOMAIN` +- Remove subdomain logic from URL construction + +### 2. Add VITE_DPS_API_PATH Support +- Add logic to read `VITE_DPS_API_PATH` environment variable +- If the variable exists and is not empty, prepend "/" to create the path +- Append the path before "/graphql" in the final URL +- Handle the case where the variable is not set or empty (no additional path) + +### 3. URL Construction Logic +The final URL should follow this pattern: +`{base_url}{optional_path}/graphql` + +Where: +- `base_url` is constructed without the subdomain +- `optional_path` is `/${VITE_DPS_API_PATH}` if the variable is set and not empty + +## Implementation Details + +### Function Structure +```typescript +export function getAuthApiUrl(): string { + // Get base URL without subdomain + // Get path from VITE_DPS_API_PATH (if exists) + // Construct final URL: base + optional_path + "/graphql" + // Return the complete URL +} +``` + +### Environment Variable Handling +- `VITE_DPS_API_PATH`: Optional path string without leading slash + - Example: "api" → results in "/api" + - Example: "" or undefined → results in "" (no additional path) +- Handle edge cases where the variable might contain whitespace + +### Examples +- If `VITE_DPS_API_PATH="api"`: URL ends with `/api/graphql` +- If `VITE_DPS_API_PATH=""`: URL ends with `/graphql` +- If `VITE_DPS_API_PATH` is undefined: URL ends with `/graphql` + +## Files to Modify +- `src/lib/utils/getAuthApiUrl.ts` +- `src/lib/utils/getAuthApiUrl.test.ts` (new test file) + +## Testing + +### Test File Creation +Create `src/lib/utils/getAuthApiUrl.test.ts` with comprehensive tests using `bun:test`. + +### Test Structure +```typescript +import { describe, it, expect } from 'bun:test' +import { getAuthApiUrl } from './getAuthApiUrl' + +describe('getAuthApiUrl', () => { + // Test cases here +}) +``` + +### Test Cases +- it('returns correct URL when VITE_DPS_API_PATH is "api"') +- it('returns correct URL when VITE_DPS_API_PATH is empty string') +- it('returns correct URL when VITE_DPS_API_PATH is undefined') +- it('returns correct URL when VITE_DPS_API_PATH contains whitespace') +- it('returns correct URL when VITE_DPS_API_PATH is "v1/api"') + +### Test Execution +After implementation, run tests with: +```bash +bun test src/lib/utils/getAuthApiUrl.test.ts +``` + +### Test Guidelines +- Use `bun:test` framework +- Use `describe`, `it`, and `expect` functions +- Do not use "should" in test descriptions +- Focus on what the function returns, not what it "should" do +- Test edge cases and different environment variable values + +## AGENTS.md Update +Add testing guidelines to AGENTS.md under the Testing section to document the use of `bun:test` framework and test description standards. \ No newline at end of file diff --git a/docs/llm/plans/2025/11/26-update-route-guard-to-async.md b/docs/llm/plans/2025/11/26-update-route-guard-to-async.md new file mode 100644 index 0000000..8fc11cd --- /dev/null +++ b/docs/llm/plans/2025/11/26-update-route-guard-to-async.md @@ -0,0 +1,191 @@ +# Plan: Update Route Guard to be Async + +## Problem Analysis + +The current route guard in `src/lib/router.ts:31-42` uses `authStore.isAuthenticated` which is a synchronous computed property. However, authentication state depends on the `authMe` mutation being called to fetch session information from the backend. The guard needs to wait for this async operation to complete before making routing decisions. + +## Current Issues + +1. **Race Condition**: The guard checks `isAuthenticated` before the `me()` method completes +2. **No Session Initialization**: There's no automatic session checking on app startup +3. **Synchronous Guard**: Current guard cannot wait for async operations +4. **Delayed Session Check**: Session validation only happens when guard runs, not when app starts + +## Proposed Solutions + +### Approach 1: Promise-based Session Tracking (Recommended) + +**Concept**: Add a `sessionInfoPromise` to the auth store that tracks the current session validation attempt. + +**Pros**: +- Clean separation of concerns +- Promise can be cached and reused +- Easy to understand and debug +- Supports concurrent calls without duplicate requests +- **Eager initialization**: Session check starts immediately when store is created +- **Never null**: `sessionInfoPromise` always holds a Promise value +- **Leverages hoisting**: Can call `me()` before declaration for cleaner code + +**Cons**: +- Requires understanding of function hoisting +- Promise management needs to handle re-calls properly + +**Implementation**: +```typescript +// In auth store - using eager initialization with hoisting +const sessionInfoPromise = ref(me().catch(() => {})) + +async function ensureSession() { + // Promise is always available, never null + return sessionInfoPromise.value +} + +// me() is available above due to function hoisting +async function me() { + sessionInfoPromise.value = _authMe() + .then(user => { + sessionData.value = { + username: user.username, + userId: user.userId, + uuid: user.uuid + } + return user + }) + .catch(error => { + sessionData.value = null + throw error + }) + + return sessionInfoPromise.value +} + +// Reset promise on logout +async function logout() { + await _authLogout() + sessionData.value = null + // Start new session check immediately + sessionInfoPromise.value = me().catch(() => {}) +} +``` + +### Approach 2: Loading State with Retry + +**Concept**: Add an `authChecking` state and make the guard wait/check/retry. + +**Pros**: +- Simpler promise management +- Clear loading state for UI +- More explicit about what's happening + +**Cons**: +- More complex guard logic +- Potential for infinite retries if not careful +- May require timeout handling + +### Approach 3: Async Computed Property + +**Concept**: Create an async computed property that resolves when auth state is known. + +**Pros**: +- Vue 3 Composition API friendly +- Reactive and declarative + +**Cons**: +- Requires additional libraries (vue-async-computed) +- May be overkill for this use case +- Less control over timing + +### Approach 4: Store Initialization Pattern + +**Concept**: Initialize the auth store before router creation, ensuring session is known. + +**Pros**: +- Simple and straightforward +- No changes needed to guard logic +- Predictable startup sequence + +**Cons**: +- Blocks app startup on network request +- Poor UX if network is slow +- May show blank screen during initialization + +## Vue Router Async Support + +**Yes, Vue Router fully supports async navigation guards**. The `beforeEach` guard can return a Promise, and Vue Router will wait for it to resolve before proceeding. + +```typescript +router.beforeEach(async (to, from, next) => { + const authStore = useAuthStore() + + if (to.meta.requiresAuth) { + await authStore.ensureSession() // Always awaits a valid promise, never null + if (!authStore.isAuthenticated) { + next({ name: 'login', query: { redirect: to.fullPath } }) + } else { + next() + } + } else { + next() + } +}) +``` + +## Recommended Implementation Plan + +### 1. Update Auth Store (`src/stores/auth.ts`) + +Add the following to the auth store using eager initialization: +- `sessionInfoPromise` ref initialized immediately with `me()` call (leveraging function hoisting) +- `ensureSession()` method that always returns the existing promise (never null) +- Update `me()` to manage the promise state and handle re-calls +- Update `logout()` to reset the promise with a new `me()` call +- Leverage JavaScript function hoisting to call `me()` before its declaration + +**Key optimization**: Session check starts immediately when store is created, not when first needed. + +### 2. Update Router Guard (`src/lib/router.ts`) + +Convert the `beforeEach` guard to async: +- Use `await authStore.ensureSession()` before checking `isAuthenticated` +- No null checks needed since `sessionInfoPromise` is always defined +- Keep the same redirect logic but make it async-aware + +### 3. App Initialization (`src/main.ts`) + +No additional initialization needed - the eager initialization in the store handles this automatically. + +## Files to Modify + +1. `src/stores/auth.ts` - Add eager-initialized promise-based session tracking +2. `src/lib/router.ts` - Convert guard to async (no null checks needed) + +## Testing Considerations + +- Test guard behavior with slow network responses +- Test concurrent route navigation during session checking +- Test promise caching (multiple rapid calls should use same promise) +- Test promise invalidation after logout +- Test error handling when `me()` fails + +## Edge Cases to Handle + +1. **Network Errors**: What happens when `me()` fails? (Handled by catch in eager init) +2. **Timeouts**: Should we add a timeout to session checking? +3. **Concurrent Navigation**: Multiple route changes during session check +4. **Promise Rejection**: Handling when the session promise rejects (eager init prevents unhandled rejections) +5. **Function Re-calls**: What happens when `me()` is called again while promise is pending? + +## Your Proposed Solution Analysis + +Your idea of exposing `sessionInfoPromise` in the auth store is **excellent** and aligns with Approach 1. With the eager initialization optimization, this approach: + +- ✅ Solves the race condition +- ✅ Prevents duplicate `me()` calls +- ✅ Is clean and maintainable +- ✅ Works well with Vue Router's async guard support +- ✅ Allows easy promise invalidation/replacement +- ✅ **Starts session check immediately** when store is created (no delay) +- ✅ **Never has null values** - promise is always available +- ✅ **Leverages function hoisting** for elegant code organization + +The only additional consideration is error handling - what should happen if the `me()` call fails? The guard should handle this gracefully and redirect to login. The eager initialization uses `.catch(() => {})` to prevent unhandled promise rejections while still preserving the error state for the guard to handle. \ No newline at end of file diff --git a/docs/llm/plans/2025/11/27-implement-global-toast-system.md b/docs/llm/plans/2025/11/27-implement-global-toast-system.md new file mode 100644 index 0000000..d9940c0 --- /dev/null +++ b/docs/llm/plans/2025/11/27-implement-global-toast-system.md @@ -0,0 +1,122 @@ +# 27-implement-global-toast-system.md + +## Overview +Implement a global toast notification system using Shadcn-Vue's toast component to enable flash messages for user actions. The system will support stacking notifications, auto-dismissal, and manual dismissal via close button. + +## Implementation Plan + +### 1. Install Shadcn-Vue Toast Component +- Run command: `bunx shadcn-vue@latest add toast` +- This will install the toast component files in `src/components/ui/toast/` + +### 2. Add Global Toaster to App.vue +- Import the `Toaster` component in `src/App.vue` +- Add it to the template to make toasts available globally +- Import required CSS styles +- Configure positioning to bottom-center +- Apply width constraints to match main layout width + +### 3. Create Toast Composable +- Create `src/composables/useToast.ts` for a global toast interface +- Export a simplified toast API that wraps Shadcn's `useToast` +- Provide methods for success, error, info, and warning toasts +- Include default options for auto-dismissal timing + +### 4. Update Main App Component +- Modify `src/App.vue` to include the Toaster component +- Ensure proper positioning and z-index for toast visibility + +### 5. Integration Examples +- Update existing views to demonstrate toast usage: + - `src/views/LoginView.vue` - Show success toast only on successful login + - `src/views/RegisterView.vue` - No toast notifications + - `src/views/PasswordChangeView.vue` - Show success toast on password change + - Create logout utility with toast notification for use across multiple views + +## Technical Details + +### Files to Create/Modify + +#### New Files: +- `src/composables/useToast.ts` - Global toast composable +- `src/lib/logout.ts` - Logout utility with toast notification + +#### Modified Files: +- `src/App.vue` - Add Toaster component +- `src/views/LoginView.vue` - Add success toast on login +- `src/views/PasswordChangeView.vue` - Add success toast on password change +- Any components that call logout - Update to use new logout utility + +### Toast Component Structure +The Shadcn-Vue toast system provides: +- `useToast()` hook for triggering toasts +- `Toaster` component for rendering notifications +- Built-in support for: + - Auto-dismissal after configurable duration + - Manual dismiss button (X button) + - Stacking multiple notifications + - Different variants (default, success, destructive) + +### Composable API Design +```typescript +// src/composables/useToast.ts +export const useToast = () => { + return { + success: (message: string, options?: ToastOptions) => void, + error: (message: string, options?: ToastOptions) => void, + info: (message: string, options?: ToastOptions) => void, + warning: (message: string, options?: ToastOptions) => void, + dismiss: (id?: string) => void + } +} +``` + +### Default Configuration +- Auto-dismiss after 5 seconds for success/info messages +- Auto-dismiss after 8 seconds for error/warning messages +- Position: bottom-center +- Maximum 3 toasts visible simultaneously +- Support for manual dismissal via X button +- Toast width constrained to main layout width (responsive) + +### Usage Examples +```typescript +// In any component or composable +const { toast } = useToast() + +// Success message +toast.success('Login successful!') + +// Error message +toast.error('Invalid credentials') + +// With custom options +toast.info('Processing your request...', { duration: 10000 }) + +// Using logout utility +import { logoutWithToast } from '@/lib/logout' +logoutWithToast() // Shows "Logged out successfully" toast +``` + +## Benefits +- Global access from any component without additional imports +- Consistent styling and behavior across the application +- Accessibility features built into Shadcn components +- TypeScript support for type safety +- Responsive design that works on all screen sizes +- Smooth animations and transitions +- Bottom-center positioning for better mobile UX +- Width-constrained toasts that respect layout boundaries + +## Dependencies +- Uses existing Shadcn-Vue installation +- No additional packages required +- Leverages existing TailwindCSS setup +- Compatible with current Vue 3 + TypeScript setup + +## Logout Utility Design +The logout utility will: +- Handle the actual logout process (clear auth state, redirect, etc.) +- Show a success toast notification: "Logged out successfully" +- Be importable from any component that needs logout functionality +- Ensure consistent logout behavior across the application \ No newline at end of file diff --git a/docs/llm/plans/2025/11/27-implement-homeview.md b/docs/llm/plans/2025/11/27-implement-homeview.md new file mode 100644 index 0000000..173f884 --- /dev/null +++ b/docs/llm/plans/2025/11/27-implement-homeview.md @@ -0,0 +1,88 @@ +# 27-Implement HomeView Dashboard + +## Overview +Implement the HomeView as a dashboard for logged-in users, displaying user information and action buttons in a card layout similar to LoginView. + +## Implementation Details + +### Files to Modify +- `src/views/HomeView.vue` - Complete rewrite from current placeholder + +### Implementation Plan + +#### 1. Component Structure +- Use composition API with ` + + +``` + +## Notes +- All button functionality is intentionally left as placeholders per requirements +- Change username button removed as backend does not support this feature +- Using `destructive` variant for logout button to provide visual warning +- Responsive button layout: vertical stack on mobile, horizontal row on larger screens (`sm:` breakpoint) +- Card layout maintains consistency with LoginView design +- Responsive design with max-width constraint +- Uses existing UI components from the project's Shadcn-Vue setup \ No newline at end of file diff --git a/docs/llm/plans/2025/11/27-implement-logout.md b/docs/llm/plans/2025/11/27-implement-logout.md new file mode 100644 index 0000000..e494275 --- /dev/null +++ b/docs/llm/plans/2025/11/27-implement-logout.md @@ -0,0 +1,89 @@ +# Implement Logout Functionality + +## Current State Analysis +- HomeView has a "Log out" button with empty `handleLogout()` function +- Auth store has a complete `logout()` method that calls `_authLogout()` and clears session data +- Router has auth guards that redirect unauthenticated users to login + +## Implementation Options + +### Option 1: Direct Logout (Recommended) +- Call `authStore.logout()` directly from HomeView +- Redirect to login page after successful logout +- No additional view needed +- Pros: Simple, fast, follows common UX patterns +- Cons: No confirmation dialog + +### Option 2: Confirmation Dialog +- Show a confirmation modal before logging out +- Use existing UI components (Card, Button) to create modal +- Pros: Prevents accidental logouts +- Cons: More complex, additional state management + +### Option 3: Dedicated Logout View +- Create `/logout` route that handles logout and redirects +- Pros: Clean separation of concerns +- Cons: Unnecessary navigation step, poor UX + +## Recommended Implementation (Option 1) + +### Files to Modify +1. `src/views/HomeView.vue` - Implement `handleLogout()` function and add loading state +2. `src/stores/auth.ts` - Verify logout method properly sets loading state + +### Implementation Details + +#### HomeView.vue Updates +```typescript +// In HomeView.vue +async function handleLogout() { + await authStore.logout(); + router.push({ name: 'login' }); +} +``` + +#### Button Loading State +```vue + +``` + +#### Auth Store Verification +The logout method already properly sets loading state: +- Sets `loading.value = true` at start +- Sets `loading.value = false` in finally block +- This ensures loading state is always cleared, even on errors + +### Additional Enhancements (Optional) +- Show toast/notification on successful logout +- Handle logout errors more gracefully (currently just logs to console) + +## Rationale +Direct logout is the most common pattern in modern web apps. Users expect logout to be immediate, and the auth store already handles the backend communication and session cleanup. The router guards will automatically handle redirecting to login if the user tries to access protected routes after logout. + +## Testing Steps +1. Click logout button +2. Verify user is redirected to login page +3. Verify accessing protected routes redirects to login +4. Verify login works after logout +5. Verify buttons are disabled during logout process +6. Verify loading spinner appears on logout button +7. Verify loading state is cleared after logout completes (success or error) \ No newline at end of file diff --git a/docs/llm/plans/2025/11/27-implement-passwordchangeview.md b/docs/llm/plans/2025/11/27-implement-passwordchangeview.md new file mode 100644 index 0000000..78536be --- /dev/null +++ b/docs/llm/plans/2025/11/27-implement-passwordchangeview.md @@ -0,0 +1,204 @@ +# Implement PasswordChangeView + +**Date**: 2025-11-27@12:48 +**Task**: Create a password change view with authentication requirement + +## Overview +Create a new `PasswordChangeView` component that allows authenticated users to change their password. The view should follow the same layout pattern as `RegisterView` and include proper form validation, loading states, and error handling. + +## Implementation Details + +### Files to Create/Modify + +#### 1. Create `src/views/PasswordChangeView.vue` +- Follow the same layout structure as `RegisterView.vue` +- Use the same UI components (Card, Field, Input, Button, Spinner) +- Implement three form fields: + - Current password (type="password", autocomplete="current-password") + - New password (type="password", autocomplete="new-password") + - New password confirmation (type="password", autocomplete="new-password") +- Add component-level loading state management during form submission +- Display error messages using `FieldError` component +- Redirect to 'home' route on successful password change + +#### 2. Modify `src/lib/router.ts` +- Add new route for password change: + ```typescript + { + path: '/password-change', + name: 'password-change', + component: PasswordChangeView, + meta: { requiresAuth: true } + } + ``` +- Import the new `PasswordChangeView` component + +#### 3. Update `src/stores/auth.ts` +- Add `changePasswordLastError` ref to store password change specific errors +- Modify the existing `changePassword` method to use this new error state instead of `loginLastError` +- Remove loading state management from the changePassword method since loading will be handled at component level + +## Code Structure + +### PasswordChangeView.vue Template Structure +```vue + +``` + +### PasswordChangeView.vue Script Structure +```vue + +``` + +### Auth Store Updates +Add to `src/stores/auth.ts`: +```typescript +const changePasswordLastError = ref(null); + +// In changePassword method: +catch (error: any) { + changePasswordLastError.value = error.message || 'Password change failed'; + return false; +} + +// Add to return object: +changePasswordLastError, +``` + +## Password Manager Compatibility +- Use proper `autocomplete` attributes: + - `current-password` for current password field + - `new-password` for new password fields +- Use semantic `name` attributes that match the field purposes +- Ensure proper form structure with labels + +## Error Handling +- Display password change specific errors under the submit button +- Use `FieldError` component for consistent styling +- Do not clear form fields on error to allow user to retry +- Re-enable all fields when error occurs + +## Loading States +- Disable all input fields and submit button during API call +- Show spinner with "Please wait…" message in button +- Use component-level `loading` ref to avoid conflicts with `authStore.loading` (reserved for session retrieval) + +## Success Flow +- On successful password change, redirect to 'home' route using `router.replace()` +- This prevents back navigation to the password change form \ No newline at end of file diff --git a/docs/llm/plans/2025/11/27-update-logindropdown-session-state.md b/docs/llm/plans/2025/11/27-update-logindropdown-session-state.md new file mode 100644 index 0000000..b470d17 --- /dev/null +++ b/docs/llm/plans/2025/11/27-update-logindropdown-session-state.md @@ -0,0 +1,121 @@ +# 27-update-logindropdown-session-state.md + +## Plan: Update LoginDropdown to Check Session State + +### Current Issues +- LoginDropdown uses hardcoded `v-if="true"` placeholder for not-signed-in state +- Logged-in state shows hardcoded "Username" instead of actual username from auth store +- Component doesn't react to authentication state changes + +### Implementation Details + +#### Files to Modify +- `src/components/LoginDropdown.vue` + +#### Changes Required + +1. **Import auth store** + - Add import for `useAuthStore` from `@/stores/auth` + +2. **Use auth store in component** + - Initialize auth store using `useAuthStore()` + - Use `isAuthenticated` computed property to determine login state + - Access `sessionData.username` for displaying username + +3. **Update template logic** + - Replace `v-if="true"` with `v-if="!authStore.isAuthenticated"` + - Replace `v-else` with `v-if="authStore.isAuthenticated"` + - Replace hardcoded "Username" with `{{ authStore.sessionData?.username }}` + +4. **Implement logout functionality** + - Add logout handler function that calls `authStore.logout()` and redirects to login page + - Follow pattern from HomeView: `await authStore.logout(); router.push({ name: 'login' })` + - Add loading state to logout button: `:disabled="authStore.loading"` + - Show spinner and "Logging out..." text when loading + +5. **Add loading state indicator** + - Add loading spinner that replaces entire component content when `authStore.loading` is true + - Use existing Spinner component with "Loading..." text + - Loading state should be top-level condition, taking precedence over auth states + - Prevents user interaction during login/logout transitions + +6. **Handle loading state (optional enhancement)** + - Consider additional loading indicators if needed + +#### Code Changes + +**Script section additions:** +```typescript +import { useAuthStore } from '@/stores/auth' +import { useRouter } from 'vue-router' +import Spinner from '@/components/ui/spinner/Spinner.vue' + +const authStore = useAuthStore() +const router = useRouter() + +async function handleLogout() { + await authStore.logout() + router.push({ name: 'login' }) +} +``` + +**Template changes:** +```vue + +
+ + + + + + +``` + +**Logout dropdown item update:** +```vue + + + + + {{ authStore.loading ? 'Logging out...' : 'Log out' }} + +``` + +**Loading state wrapper:** +```vue + + +``` + +### Notes +- The auth store automatically initializes session checking on creation +- `isAuthenticated` computed property will react to session changes +- `sessionData?.username` safely handles null case with optional chaining +- No additional packages needed - uses existing Pinia store \ No newline at end of file diff --git a/docs/llm/plans/2025/12/06-implement-admin-sites-add-site.md b/docs/llm/plans/2025/12/06-implement-admin-sites-add-site.md new file mode 100644 index 0000000..a5fc91b --- /dev/null +++ b/docs/llm/plans/2025/12/06-implement-admin-sites-add-site.md @@ -0,0 +1,225 @@ +# 06-implement-admin-sites-add-site.md + +## Overview +Implement the Add Site functionality for the Admin panel, including a new route, view component, form handling with error display, and cache optimization for the sites list. + +## Implementation Details + +### 1. Update Router Configuration +**File**: `src/lib/router.ts` +- Add import for `AdminSitesNewView` (to be created) +- Add new child route to the admin routes array: + ```typescript + { + path: 'sites/new', + name: 'admin-sites-new', + component: AdminSitesNewView, + } + ``` + +### 2. Create AdminSitesNewView Component +**File**: `src/views/admin/AdminSitesNewView.vue` +- Create new Vue component with form for adding a site +- Use composition API with ` + + +``` + +### 3. Update AdminSitesView Button +**File**: `src/views/admin/AdminSitesView.vue` +- Update `handleAddSite()` function to navigate to new route: + ```typescript + const handleAddSite = () => { + router.push({ name: 'admin-sites-new' }) + } + ``` +- Add `useRouter` import and router instance + +### 4. Update Sites Query Cache Policy +**File**: `src/lib/auth-wrapper.ts` +- Modify `_sites()` function to use `requestPolicy: 'network-only'` (similar to `_authMe`): + ```typescript + export async function _sites(): Promise { + const result = await client.query(SITES, {}, { requestPolicy: 'network-only' }).toPromise() + if (result.error) throw result.error + return result.data.sites + } + ``` + +### 5. Form Validation and Error Handling +- Use reactive refs for form fields +- Implement basic validation (required slug field) +- Display validation errors and API errors using FieldError component +- Clear form on successful submission +- Handle loading states appropriately + +## Files to Modify/Create +1. `src/lib/router.ts` - Add new route +2. `src/views/admin/AdminSitesNewView.vue` - Create new view component +3. `src/views/admin/AdminSitesView.vue` - Update button navigation +4. `src/lib/auth-wrapper.ts` - Update cache policy for sites query + +## Dependencies +No new packages required. All necessary UI components and stores are already available in the project. + +## Testing Considerations +- Test form submission with valid data +- Test error handling with invalid data +- Test redirect after successful site creation +- Verify new site appears in sites list after creation +- Test loading states during form submission \ No newline at end of file diff --git a/docs/llm/plans/2025/12/06-implement-admin-sites-view.md b/docs/llm/plans/2025/12/06-implement-admin-sites-view.md new file mode 100644 index 0000000..29019af --- /dev/null +++ b/docs/llm/plans/2025/12/06-implement-admin-sites-view.md @@ -0,0 +1,125 @@ +# 06-implement-admin-sites-view.md + +## Overview +Update the existing AdminSitesView component to display a table of sites from the existing site store with edit and delete action buttons. + +## Implementation Details + +### Files to Modify + +#### 1. Update `src/views/admin/AdminSitesView.vue` +- Replace the existing placeholder content with a proper Vue component using composition API with ` +``` + +#### Template Section +```vue + +``` + +### Dependencies +- All required components already exist in the project: + - Table components from `@/components/ui/table` + - Button component from `@/components/ui/button` + - Site store from `@/stores/site` + +### Notes +- The site store automatically fetches sites on initialization, so no manual fetch needed +- Site interface has: `id: number`, `slug: string`, `subdomain: string`, `port: number`, `protocol: string`, `metadataJson?: string`, `createdTs: number`, `updatedTs: number` +- Table only displays: slug, subdomain, port, protocol (excludes metadataJson, createdTs, updatedTs) +- Edit and delete functions accept `number` type for site ID (matching the Site interface) +- Component handles loading state from the store +- The "Add Site" button has a placeholder handler that logs to console +- Uses TailwindCSS for styling with existing design patterns +- No pagination or filtering needed as the site list is small (< 10 items) \ No newline at end of file diff --git a/docs/llm/plans/2025/12/06-implement-tabbed-interface-in-adminapp.md b/docs/llm/plans/2025/12/06-implement-tabbed-interface-in-adminapp.md new file mode 100644 index 0000000..eddd1a5 --- /dev/null +++ b/docs/llm/plans/2025/12/06-implement-tabbed-interface-in-adminapp.md @@ -0,0 +1,91 @@ +# 06-implement-tabbed-interface-in-adminapp.md + +## Plan: Implement Tabbed Interface in AdminApp + +### Overview +Add a tabbed interface to AdminApp component with three tabs: Dashboard, Users, and Sites. The interface will be wrapped in a card component similar to LoginView. + +### Prerequisites +The tabs component from Shadcn-Vue is already installed in the project at `src/components/ui/tabs/`. + +### Implementation Steps + +#### 1. Update AdminApp.vue +- Import necessary components: Card, CardContent, and all Tabs components +- Import useRouter from Vue Router +- Add reactive state to track active tab based on current route +- Implement tab switching logic with router navigation +- Replace the current template with a card containing the tabbed interface + +#### 2. Tab Configuration +- Dashboard tab → routes to 'admin-home' +- Users tab → routes to 'admin-users' +- Sites tab → routes to 'admin-sites' + +#### 3. Layout Structure +```vue + + + +``` + +#### 4. Script Logic +- Use computed property to determine active tab from current route +- Leverage Shadcn-Vue's `as-child` prop to wrap TabsTrigger with router-link +- Let Vue Router handle all navigation and state management +- No manual state synchronization needed - router-link handles active states automatically + +### Files to Modify +- `src/views/admin/AdminApp.vue` - Complete rewrite to implement tabbed interface + +### Technical Details +- Preserve the existing div wrapper with `w-full max-w-4xl items-start h-full p-4` classes +- Use full width for the tabbed interface (`w-full` class) +- Card wrapper will provide consistent styling with LoginView +- Router-view will be rendered inside the active tab content +- Tab state will sync with current route for proper navigation + +### Dependencies +All required components are already available: +- Card components: `src/components/ui/card/` +- Tabs components: `src/components/ui/tabs/` +- Vue Router: already configured in the project \ No newline at end of file diff --git a/docs/llm/plans/2025/12/06-update-auth-store-with-schema-changes.md b/docs/llm/plans/2025/12/06-update-auth-store-with-schema-changes.md new file mode 100644 index 0000000..b63bd4b --- /dev/null +++ b/docs/llm/plans/2025/12/06-update-auth-store-with-schema-changes.md @@ -0,0 +1,93 @@ +# 06-update-auth-store-with-schema-changes.md + +## Analysis + +After comparing the current auth store implementation with the latest backend GraphQL schema, I've identified several missing fields and inconsistencies that need to be addressed: + +### Current Issues Found: + +1. **Missing role information**: The backend schema returns `roleId` and `roleName` in `authMe`, `authRegister`, and other user-related operations, but the auth store doesn't store or handle these fields. + + + +3. **Inconsistent session data structure**: The `sessionData` object in the store has an inconsistent structure - sometimes includes `token`, sometimes includes `uuid`, but doesn't include the role information. + +4. **Unnecessary token storage**: The `token` field is returned by `authLogin` but the frontend relies on cookies for authentication, making token storage redundant. + +5. **Incomplete type safety**: The session data type definition doesn't match the complete backend response structure. + +## Implementation Plan + +### Files to Modify: + +1. **`src/stores/auth.ts`** - Main auth store implementation +2. **`src/lib/auth-wrapper.ts`** - Update GraphQL queries to only request needed fields + +### Changes Required: + +#### 1. Update Session Data Type Definition +- Extend the `sessionData` type to include role fields returned by `authMe` +- Add `roleId`, `roleName` +- Remove `token` field entirely since frontend uses cookies for authentication +- Note: User timestamps (`createdTs`, `updatedTs`) and session expiration (`sessionIat`, `sessionExp`) are not needed in the frontend + +#### 2. Update Login Method +- Remove token storage from login response (authLogin only returns token, userId, username, message) +- Call `me()` after successful login to get complete user data including role information and uuid +- Store only the complete user profile data from `me()`, not the limited login response + +#### 3. Update Register Method +- Store `roleId` from registration response +- Handle the complete user profile returned by registration + +#### 4. Update `me()` Method +- Store relevant fields returned by `authMe` query +- Ensure session data includes role information + +#### 5. Update GraphQL Queries in auth-wrapper.ts +- Update `AUTH_ME` query to request only: `userId`, `uuid`, `username`, `roleId`, `roleName` +- Update `AUTH_REGISTER` query to request only: `userId`, `uuid`, `username`, `roleId`, `message` +- Remove unnecessary fields: `createdTs`, `updatedTs`, `sessionIat`, `sessionExp` +- Update TypeScript interfaces to match the simplified query responses + + + + + + + +### Updated Session Data Type: + +```typescript +interface SessionData { + username: string + userId: number + uuid: string + roleId: number + roleName: string +} +``` + +### Benefits of These Changes: + +1. **Complete User Profile**: Store all available user information from the backend +2. **Role-Based Access Control**: Components can access `sessionData.roleName` and `sessionData.roleId` when needed +3. **Cleaner Authentication**: Remove unnecessary token storage since cookies are used for authentication +4. **Type Safety**: Improved TypeScript support with complete type definitions +5. **Future-Proof**: Ready for admin features that require role-based permissions + +### Testing Considerations: + +- Verify login stores complete user profile +- Verify registration stores role information +- Verify `me()` method updates role information +- Verify GraphQL queries only request needed fields +- Ensure all session fields are properly stored +- Ensure backward compatibility with existing components using the store + +### No Breaking Changes Expected: + +- Existing properties (`username`, `userId`, `isAuthenticated`) remain unchanged +- New properties are additive only +- Existing method signatures remain the same +- Token removal is internal only - no external components use the token \ No newline at end of file diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..46c8ba1 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,18 @@ +# DpsAuthWeb Roadmap + +This document outlines the planned features and improvements for the DpsAuthWeb project. + +## Outline + +- [x] Implement RegisterView +- [x] Install Pinia +- [x] Create authStore w/ placeholder code (no real logic yet) +- [x] Integrate auth store into LoginView +- [x] Integrate auth store into RegisterView +- [ ] Integrate auth store into LoginSuccessView +- [ ] Document redirect logic and workflows +- [ ] Complete HomeView code (will redirect according to session state) +- [ ] Implement Login Success View (needs "redirect back to site" logic) +- [x] Integrate with real GraphQL backend +- [ ] Document "documents" workflow (e.g., Terms of Service, Privacy Policy) +- [ ] Replace all links with route objects diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..0e14e77 --- /dev/null +++ b/opencode.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "doom_loop": "ask", + "external_directory": "ask", + "bash": { + "git *": "deny", + "npm *": "deny", + "node *": "deny" + } + } +} diff --git a/package.json b/package.json index 203ebe1..9bd2ca7 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "dps-auth-web", + "name": "@dimensionalpocket/dps-auth-web", "private": true, "version": "0.0.0", "type": "module", @@ -9,10 +9,29 @@ "preview": "bun --bun vite preview" }, "dependencies": { - "vue": "^3.5.24" + "@tailwindcss/vite": "^4.1.17", + "@tanstack/vue-table": "^8.21.3", + "@urql/exchange-retry": "^2.0.0", + "@urql/vue": "^2.0.0", + "@vueuse/core": "^14.1.0", + "@vueuse/head": "^2.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "inter-ui": "^4.1.1", + "lucide-vue-next": "^0.553.0", + "pinia": "^3.0.4", + "reka-ui": "^2.7.0", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.17", + "vue": "^3.5.24", + "vue-router": "4", + "vue-sonner": "^2.0.9" }, "devDependencies": { + "@types/node": "^24.10.1", "@vitejs/plugin-vue": "^6.0.1", + "tw-animate-css": "^1.4.0", + "typescript": "^5.9.3", "vite": "^7.2.2" } } diff --git a/src/App.vue b/src/App.vue index 2ba4163..9befbcd 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,10 +1,76 @@ diff --git a/src/components/LoginDropdown.vue b/src/components/LoginDropdown.vue new file mode 100644 index 0000000..d580155 --- /dev/null +++ b/src/components/LoginDropdown.vue @@ -0,0 +1,91 @@ + + + diff --git a/src/components/ui/button-group/ButtonGroup.vue b/src/components/ui/button-group/ButtonGroup.vue new file mode 100644 index 0000000..9dbef6a --- /dev/null +++ b/src/components/ui/button-group/ButtonGroup.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/ui/button-group/ButtonGroupSeparator.vue b/src/components/ui/button-group/ButtonGroupSeparator.vue new file mode 100644 index 0000000..e069dd5 --- /dev/null +++ b/src/components/ui/button-group/ButtonGroupSeparator.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/components/ui/button-group/ButtonGroupText.vue b/src/components/ui/button-group/ButtonGroupText.vue new file mode 100644 index 0000000..c436843 --- /dev/null +++ b/src/components/ui/button-group/ButtonGroupText.vue @@ -0,0 +1,29 @@ + + + diff --git a/src/components/ui/button-group/index.ts b/src/components/ui/button-group/index.ts new file mode 100644 index 0000000..474566f --- /dev/null +++ b/src/components/ui/button-group/index.ts @@ -0,0 +1,25 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export { default as ButtonGroup } from "./ButtonGroup.vue" +export { default as ButtonGroupSeparator } from "./ButtonGroupSeparator.vue" +export { default as ButtonGroupText } from "./ButtonGroupText.vue" + +export const buttonGroupVariants = cva( + "flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2", + { + variants: { + orientation: { + horizontal: + "[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none", + vertical: + "flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none", + }, + }, + defaultVariants: { + orientation: "horizontal", + }, + }, +) + +export type ButtonGroupVariants = VariantProps diff --git a/src/components/ui/button/Button.vue b/src/components/ui/button/Button.vue new file mode 100644 index 0000000..374320b --- /dev/null +++ b/src/components/ui/button/Button.vue @@ -0,0 +1,29 @@ + + + diff --git a/src/components/ui/button/index.ts b/src/components/ui/button/index.ts new file mode 100644 index 0000000..26e2c55 --- /dev/null +++ b/src/components/ui/button/index.ts @@ -0,0 +1,38 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export { default as Button } from "./Button.vue" + +export const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + "default": "h-9 px-4 py-2 has-[>svg]:px-3", + "sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + "lg": "h-10 rounded-md px-6 has-[>svg]:px-4", + "icon": "size-9", + "icon-sm": "size-8", + "icon-lg": "size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +) +export type ButtonVariants = VariantProps diff --git a/src/components/ui/card/Card.vue b/src/components/ui/card/Card.vue new file mode 100644 index 0000000..f5a0707 --- /dev/null +++ b/src/components/ui/card/Card.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/ui/card/CardAction.vue b/src/components/ui/card/CardAction.vue new file mode 100644 index 0000000..c91638b --- /dev/null +++ b/src/components/ui/card/CardAction.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/card/CardContent.vue b/src/components/ui/card/CardContent.vue new file mode 100644 index 0000000..dfbc552 --- /dev/null +++ b/src/components/ui/card/CardContent.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/card/CardDescription.vue b/src/components/ui/card/CardDescription.vue new file mode 100644 index 0000000..71c1b8d --- /dev/null +++ b/src/components/ui/card/CardDescription.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/card/CardFooter.vue b/src/components/ui/card/CardFooter.vue new file mode 100644 index 0000000..9e3739e --- /dev/null +++ b/src/components/ui/card/CardFooter.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/card/CardHeader.vue b/src/components/ui/card/CardHeader.vue new file mode 100644 index 0000000..4fe4da4 --- /dev/null +++ b/src/components/ui/card/CardHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/card/CardTitle.vue b/src/components/ui/card/CardTitle.vue new file mode 100644 index 0000000..5f479e7 --- /dev/null +++ b/src/components/ui/card/CardTitle.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/card/index.ts b/src/components/ui/card/index.ts new file mode 100644 index 0000000..1627758 --- /dev/null +++ b/src/components/ui/card/index.ts @@ -0,0 +1,7 @@ +export { default as Card } from "./Card.vue" +export { default as CardAction } from "./CardAction.vue" +export { default as CardContent } from "./CardContent.vue" +export { default as CardDescription } from "./CardDescription.vue" +export { default as CardFooter } from "./CardFooter.vue" +export { default as CardHeader } from "./CardHeader.vue" +export { default as CardTitle } from "./CardTitle.vue" diff --git a/src/components/ui/dialog/Dialog.vue b/src/components/ui/dialog/Dialog.vue new file mode 100644 index 0000000..ade5260 --- /dev/null +++ b/src/components/ui/dialog/Dialog.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/components/ui/dialog/DialogClose.vue b/src/components/ui/dialog/DialogClose.vue new file mode 100644 index 0000000..c5fae04 --- /dev/null +++ b/src/components/ui/dialog/DialogClose.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/ui/dialog/DialogContent.vue b/src/components/ui/dialog/DialogContent.vue new file mode 100644 index 0000000..7f86b47 --- /dev/null +++ b/src/components/ui/dialog/DialogContent.vue @@ -0,0 +1,53 @@ + + + diff --git a/src/components/ui/dialog/DialogDescription.vue b/src/components/ui/dialog/DialogDescription.vue new file mode 100644 index 0000000..f52e655 --- /dev/null +++ b/src/components/ui/dialog/DialogDescription.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/components/ui/dialog/DialogFooter.vue b/src/components/ui/dialog/DialogFooter.vue new file mode 100644 index 0000000..0a936e6 --- /dev/null +++ b/src/components/ui/dialog/DialogFooter.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/ui/dialog/DialogHeader.vue b/src/components/ui/dialog/DialogHeader.vue new file mode 100644 index 0000000..bfc3c64 --- /dev/null +++ b/src/components/ui/dialog/DialogHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/dialog/DialogOverlay.vue b/src/components/ui/dialog/DialogOverlay.vue new file mode 100644 index 0000000..7790077 --- /dev/null +++ b/src/components/ui/dialog/DialogOverlay.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/components/ui/dialog/DialogScrollContent.vue b/src/components/ui/dialog/DialogScrollContent.vue new file mode 100644 index 0000000..f2475db --- /dev/null +++ b/src/components/ui/dialog/DialogScrollContent.vue @@ -0,0 +1,59 @@ + + + diff --git a/src/components/ui/dialog/DialogTitle.vue b/src/components/ui/dialog/DialogTitle.vue new file mode 100644 index 0000000..860f01a --- /dev/null +++ b/src/components/ui/dialog/DialogTitle.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/components/ui/dialog/DialogTrigger.vue b/src/components/ui/dialog/DialogTrigger.vue new file mode 100644 index 0000000..49667e9 --- /dev/null +++ b/src/components/ui/dialog/DialogTrigger.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/ui/dialog/index.ts b/src/components/ui/dialog/index.ts new file mode 100644 index 0000000..6768b09 --- /dev/null +++ b/src/components/ui/dialog/index.ts @@ -0,0 +1,10 @@ +export { default as Dialog } from "./Dialog.vue" +export { default as DialogClose } from "./DialogClose.vue" +export { default as DialogContent } from "./DialogContent.vue" +export { default as DialogDescription } from "./DialogDescription.vue" +export { default as DialogFooter } from "./DialogFooter.vue" +export { default as DialogHeader } from "./DialogHeader.vue" +export { default as DialogOverlay } from "./DialogOverlay.vue" +export { default as DialogScrollContent } from "./DialogScrollContent.vue" +export { default as DialogTitle } from "./DialogTitle.vue" +export { default as DialogTrigger } from "./DialogTrigger.vue" diff --git a/src/components/ui/dropdown-menu/DropdownMenu.vue b/src/components/ui/dropdown-menu/DropdownMenu.vue new file mode 100644 index 0000000..e1c9ee3 --- /dev/null +++ b/src/components/ui/dropdown-menu/DropdownMenu.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue b/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue new file mode 100644 index 0000000..1253078 --- /dev/null +++ b/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue @@ -0,0 +1,39 @@ + + + diff --git a/src/components/ui/dropdown-menu/DropdownMenuContent.vue b/src/components/ui/dropdown-menu/DropdownMenuContent.vue new file mode 100644 index 0000000..7c43014 --- /dev/null +++ b/src/components/ui/dropdown-menu/DropdownMenuContent.vue @@ -0,0 +1,39 @@ + + + diff --git a/src/components/ui/dropdown-menu/DropdownMenuGroup.vue b/src/components/ui/dropdown-menu/DropdownMenuGroup.vue new file mode 100644 index 0000000..da634ec --- /dev/null +++ b/src/components/ui/dropdown-menu/DropdownMenuGroup.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/ui/dropdown-menu/DropdownMenuItem.vue b/src/components/ui/dropdown-menu/DropdownMenuItem.vue new file mode 100644 index 0000000..f56cae3 --- /dev/null +++ b/src/components/ui/dropdown-menu/DropdownMenuItem.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/components/ui/dropdown-menu/DropdownMenuLabel.vue b/src/components/ui/dropdown-menu/DropdownMenuLabel.vue new file mode 100644 index 0000000..8bca83c --- /dev/null +++ b/src/components/ui/dropdown-menu/DropdownMenuLabel.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue b/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue new file mode 100644 index 0000000..fe82cad --- /dev/null +++ b/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue b/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue new file mode 100644 index 0000000..e03c40c --- /dev/null +++ b/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue @@ -0,0 +1,40 @@ + + + diff --git a/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue b/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue new file mode 100644 index 0000000..1b936c3 --- /dev/null +++ b/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue b/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue new file mode 100644 index 0000000..60be75c --- /dev/null +++ b/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/dropdown-menu/DropdownMenuSub.vue b/src/components/ui/dropdown-menu/DropdownMenuSub.vue new file mode 100644 index 0000000..7472e77 --- /dev/null +++ b/src/components/ui/dropdown-menu/DropdownMenuSub.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue b/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue new file mode 100644 index 0000000..d7c6b08 --- /dev/null +++ b/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue b/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue new file mode 100644 index 0000000..1683aaf --- /dev/null +++ b/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue b/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue new file mode 100644 index 0000000..75cd747 --- /dev/null +++ b/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/dropdown-menu/index.ts b/src/components/ui/dropdown-menu/index.ts new file mode 100644 index 0000000..955fe3a --- /dev/null +++ b/src/components/ui/dropdown-menu/index.ts @@ -0,0 +1,16 @@ +export { default as DropdownMenu } from "./DropdownMenu.vue" + +export { default as DropdownMenuCheckboxItem } from "./DropdownMenuCheckboxItem.vue" +export { default as DropdownMenuContent } from "./DropdownMenuContent.vue" +export { default as DropdownMenuGroup } from "./DropdownMenuGroup.vue" +export { default as DropdownMenuItem } from "./DropdownMenuItem.vue" +export { default as DropdownMenuLabel } from "./DropdownMenuLabel.vue" +export { default as DropdownMenuRadioGroup } from "./DropdownMenuRadioGroup.vue" +export { default as DropdownMenuRadioItem } from "./DropdownMenuRadioItem.vue" +export { default as DropdownMenuSeparator } from "./DropdownMenuSeparator.vue" +export { default as DropdownMenuShortcut } from "./DropdownMenuShortcut.vue" +export { default as DropdownMenuSub } from "./DropdownMenuSub.vue" +export { default as DropdownMenuSubContent } from "./DropdownMenuSubContent.vue" +export { default as DropdownMenuSubTrigger } from "./DropdownMenuSubTrigger.vue" +export { default as DropdownMenuTrigger } from "./DropdownMenuTrigger.vue" +export { DropdownMenuPortal } from "reka-ui" diff --git a/src/components/ui/field/Field.vue b/src/components/ui/field/Field.vue new file mode 100644 index 0000000..5519d37 --- /dev/null +++ b/src/components/ui/field/Field.vue @@ -0,0 +1,25 @@ + + + diff --git a/src/components/ui/field/FieldContent.vue b/src/components/ui/field/FieldContent.vue new file mode 100644 index 0000000..d9a23fd --- /dev/null +++ b/src/components/ui/field/FieldContent.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/components/ui/field/FieldDescription.vue b/src/components/ui/field/FieldDescription.vue new file mode 100644 index 0000000..7240a83 --- /dev/null +++ b/src/components/ui/field/FieldDescription.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/ui/field/FieldError.vue b/src/components/ui/field/FieldError.vue new file mode 100644 index 0000000..8a0a63f --- /dev/null +++ b/src/components/ui/field/FieldError.vue @@ -0,0 +1,53 @@ + + + diff --git a/src/components/ui/field/FieldGroup.vue b/src/components/ui/field/FieldGroup.vue new file mode 100644 index 0000000..834d8ce --- /dev/null +++ b/src/components/ui/field/FieldGroup.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/components/ui/field/FieldLabel.vue b/src/components/ui/field/FieldLabel.vue new file mode 100644 index 0000000..ce6c498 --- /dev/null +++ b/src/components/ui/field/FieldLabel.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/components/ui/field/FieldLegend.vue b/src/components/ui/field/FieldLegend.vue new file mode 100644 index 0000000..c620fed --- /dev/null +++ b/src/components/ui/field/FieldLegend.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/components/ui/field/FieldSeparator.vue b/src/components/ui/field/FieldSeparator.vue new file mode 100644 index 0000000..97d0efa --- /dev/null +++ b/src/components/ui/field/FieldSeparator.vue @@ -0,0 +1,29 @@ + + + diff --git a/src/components/ui/field/FieldSet.vue b/src/components/ui/field/FieldSet.vue new file mode 100644 index 0000000..7be4dc9 --- /dev/null +++ b/src/components/ui/field/FieldSet.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/components/ui/field/FieldTitle.vue b/src/components/ui/field/FieldTitle.vue new file mode 100644 index 0000000..f564b8b --- /dev/null +++ b/src/components/ui/field/FieldTitle.vue @@ -0,0 +1,20 @@ + + + diff --git a/src/components/ui/field/index.ts b/src/components/ui/field/index.ts new file mode 100644 index 0000000..162ba14 --- /dev/null +++ b/src/components/ui/field/index.ts @@ -0,0 +1,39 @@ +import type { VariantProps } from "class-variance-authority" +import { cva } from "class-variance-authority" + +export const fieldVariants = cva( + "group/field flex w-full gap-3 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], + horizontal: [ + "flex-row items-center", + "[&>[data-slot=field-label]]:flex-auto", + "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + responsive: [ + "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto", + "@md/field-group:[&>[data-slot=field-label]]:flex-auto", + "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + ], + }, + }, + defaultVariants: { + orientation: "vertical", + }, + }, +) + +export type FieldVariants = VariantProps + +export { default as Field } from "./Field.vue" +export { default as FieldContent } from "./FieldContent.vue" +export { default as FieldDescription } from "./FieldDescription.vue" +export { default as FieldError } from "./FieldError.vue" +export { default as FieldGroup } from "./FieldGroup.vue" +export { default as FieldLabel } from "./FieldLabel.vue" +export { default as FieldLegend } from "./FieldLegend.vue" +export { default as FieldSeparator } from "./FieldSeparator.vue" +export { default as FieldSet } from "./FieldSet.vue" +export { default as FieldTitle } from "./FieldTitle.vue" diff --git a/src/components/ui/input/Input.vue b/src/components/ui/input/Input.vue new file mode 100644 index 0000000..e5135c1 --- /dev/null +++ b/src/components/ui/input/Input.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/components/ui/input/index.ts b/src/components/ui/input/index.ts new file mode 100644 index 0000000..9976b86 --- /dev/null +++ b/src/components/ui/input/index.ts @@ -0,0 +1 @@ +export { default as Input } from "./Input.vue" diff --git a/src/components/ui/label/Label.vue b/src/components/ui/label/Label.vue new file mode 100644 index 0000000..ee63970 --- /dev/null +++ b/src/components/ui/label/Label.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/components/ui/label/index.ts b/src/components/ui/label/index.ts new file mode 100644 index 0000000..036e35c --- /dev/null +++ b/src/components/ui/label/index.ts @@ -0,0 +1 @@ +export { default as Label } from "./Label.vue" diff --git a/src/components/ui/separator/Separator.vue b/src/components/ui/separator/Separator.vue new file mode 100644 index 0000000..78d60ec --- /dev/null +++ b/src/components/ui/separator/Separator.vue @@ -0,0 +1,29 @@ + + + diff --git a/src/components/ui/separator/index.ts b/src/components/ui/separator/index.ts new file mode 100644 index 0000000..4407287 --- /dev/null +++ b/src/components/ui/separator/index.ts @@ -0,0 +1 @@ +export { default as Separator } from "./Separator.vue" diff --git a/src/components/ui/sonner/Sonner.vue b/src/components/ui/sonner/Sonner.vue new file mode 100644 index 0000000..6830896 --- /dev/null +++ b/src/components/ui/sonner/Sonner.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/components/ui/sonner/index.ts b/src/components/ui/sonner/index.ts new file mode 100644 index 0000000..6673112 --- /dev/null +++ b/src/components/ui/sonner/index.ts @@ -0,0 +1 @@ +export { default as Toaster } from "./Sonner.vue" diff --git a/src/components/ui/spinner/Spinner.vue b/src/components/ui/spinner/Spinner.vue new file mode 100644 index 0000000..57cd1a9 --- /dev/null +++ b/src/components/ui/spinner/Spinner.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/spinner/index.ts b/src/components/ui/spinner/index.ts new file mode 100644 index 0000000..aa63007 --- /dev/null +++ b/src/components/ui/spinner/index.ts @@ -0,0 +1 @@ +export { default as Spinner } from "./Spinner.vue" diff --git a/src/components/ui/table/Table.vue b/src/components/ui/table/Table.vue new file mode 100644 index 0000000..0d0cd9b --- /dev/null +++ b/src/components/ui/table/Table.vue @@ -0,0 +1,16 @@ + + + diff --git a/src/components/ui/table/TableBody.vue b/src/components/ui/table/TableBody.vue new file mode 100644 index 0000000..d14a2d3 --- /dev/null +++ b/src/components/ui/table/TableBody.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/table/TableCaption.vue b/src/components/ui/table/TableCaption.vue new file mode 100644 index 0000000..3630084 --- /dev/null +++ b/src/components/ui/table/TableCaption.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/table/TableCell.vue b/src/components/ui/table/TableCell.vue new file mode 100644 index 0000000..d6e9ed2 --- /dev/null +++ b/src/components/ui/table/TableCell.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/ui/table/TableEmpty.vue b/src/components/ui/table/TableEmpty.vue new file mode 100644 index 0000000..9519328 --- /dev/null +++ b/src/components/ui/table/TableEmpty.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/components/ui/table/TableFooter.vue b/src/components/ui/table/TableFooter.vue new file mode 100644 index 0000000..29e0ce9 --- /dev/null +++ b/src/components/ui/table/TableFooter.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/table/TableHead.vue b/src/components/ui/table/TableHead.vue new file mode 100644 index 0000000..f83efe5 --- /dev/null +++ b/src/components/ui/table/TableHead.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/table/TableHeader.vue b/src/components/ui/table/TableHeader.vue new file mode 100644 index 0000000..b4ab5cf --- /dev/null +++ b/src/components/ui/table/TableHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/table/TableRow.vue b/src/components/ui/table/TableRow.vue new file mode 100644 index 0000000..8f1d172 --- /dev/null +++ b/src/components/ui/table/TableRow.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/table/index.ts b/src/components/ui/table/index.ts new file mode 100644 index 0000000..3be308b --- /dev/null +++ b/src/components/ui/table/index.ts @@ -0,0 +1,9 @@ +export { default as Table } from "./Table.vue" +export { default as TableBody } from "./TableBody.vue" +export { default as TableCaption } from "./TableCaption.vue" +export { default as TableCell } from "./TableCell.vue" +export { default as TableEmpty } from "./TableEmpty.vue" +export { default as TableFooter } from "./TableFooter.vue" +export { default as TableHead } from "./TableHead.vue" +export { default as TableHeader } from "./TableHeader.vue" +export { default as TableRow } from "./TableRow.vue" diff --git a/src/components/ui/table/utils.ts b/src/components/ui/table/utils.ts new file mode 100644 index 0000000..3d4fd12 --- /dev/null +++ b/src/components/ui/table/utils.ts @@ -0,0 +1,10 @@ +import type { Updater } from "@tanstack/vue-table" + +import type { Ref } from "vue" +import { isFunction } from "@tanstack/vue-table" + +export function valueUpdater(updaterOrValue: Updater, ref: Ref) { + ref.value = isFunction(updaterOrValue) + ? updaterOrValue(ref.value) + : updaterOrValue +} diff --git a/src/components/ui/tabs/Tabs.vue b/src/components/ui/tabs/Tabs.vue new file mode 100644 index 0000000..d260a15 --- /dev/null +++ b/src/components/ui/tabs/Tabs.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/components/ui/tabs/TabsContent.vue b/src/components/ui/tabs/TabsContent.vue new file mode 100644 index 0000000..3186ee8 --- /dev/null +++ b/src/components/ui/tabs/TabsContent.vue @@ -0,0 +1,21 @@ + + + diff --git a/src/components/ui/tabs/TabsList.vue b/src/components/ui/tabs/TabsList.vue new file mode 100644 index 0000000..a64a2da --- /dev/null +++ b/src/components/ui/tabs/TabsList.vue @@ -0,0 +1,24 @@ + + + diff --git a/src/components/ui/tabs/TabsTrigger.vue b/src/components/ui/tabs/TabsTrigger.vue new file mode 100644 index 0000000..45e424f --- /dev/null +++ b/src/components/ui/tabs/TabsTrigger.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/components/ui/tabs/index.ts b/src/components/ui/tabs/index.ts new file mode 100644 index 0000000..7f99b7f --- /dev/null +++ b/src/components/ui/tabs/index.ts @@ -0,0 +1,4 @@ +export { default as Tabs } from "./Tabs.vue" +export { default as TabsContent } from "./TabsContent.vue" +export { default as TabsList } from "./TabsList.vue" +export { default as TabsTrigger } from "./TabsTrigger.vue" diff --git a/src/composables/useToast.ts b/src/composables/useToast.ts new file mode 100644 index 0000000..ef25cd1 --- /dev/null +++ b/src/composables/useToast.ts @@ -0,0 +1,52 @@ +import { toast } from 'vue-sonner' + +export interface ToastOptions { + duration?: number + position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right' + action?: { + label: string + onClick: () => void + } +} + +export const useToast = () => { + return { + success: (message: string, options?: ToastOptions) => { + return toast.success(message, { + duration: options?.duration || 5000, + position: options?.position || 'bottom-center', + ...options + }) + }, + error: (message: string, options?: ToastOptions) => { + return toast.error(message, { + duration: options?.duration || 8000, + position: options?.position || 'bottom-center', + ...options + }) + }, + info: (message: string, options?: ToastOptions) => { + return toast.info(message, { + duration: options?.duration || 5000, + position: options?.position || 'bottom-center', + ...options + }) + }, + warning: (message: string, options?: ToastOptions) => { + return toast.warning(message, { + duration: options?.duration || 8000, + position: options?.position || 'bottom-center', + ...options + }) + }, + loading: (message: string, options?: ToastOptions) => { + return toast.loading(message, { + position: options?.position || 'bottom-center', + ...options + }) + }, + dismiss: (id?: string | number) => { + toast.dismiss(id) + } + } +} \ No newline at end of file diff --git a/src/lib/auth-wrapper.ts b/src/lib/auth-wrapper.ts new file mode 100644 index 0000000..a390cf9 --- /dev/null +++ b/src/lib/auth-wrapper.ts @@ -0,0 +1,491 @@ +import { createClient, cacheExchange, fetchExchange } from '@urql/core' +import { retryExchange } from '@urql/exchange-retry' +import { getAuthApiUrl } from './utils/getAuthApiUrl' + +const client = createClient({ + url: getAuthApiUrl(), + exchanges: [ + cacheExchange, + retryExchange({ + initialDelayMs: 1000, // Retry once per second + maxDelayMs: 1000, // Keep consistent 1-second intervals + maxNumberAttempts: 60, // Retry for 60 seconds total + retryIf: error => { + // Only retry on HTTP 502 (Bad Gateway) - deployment indicator + return error.response && error.response.status === 502 + } + }), + fetchExchange, + ], + fetchOptions: { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + } + } +}) + +// GraphQL query/mutation constants +const GET_SERVER_TIMESTAMP = ` + query GetServerTimestamp { + serverTimestamp + } +` + +const AUTH_ME = ` + query AuthMe { + authMe { + user { + id + uuid + name + role { + id + name + permissions + } + createdTs + updatedTs + } + sessionIat + sessionExp + } + } +` + +const SITES = ` + query Sites { + sites { + id + slug + subdomain + port + protocol + } + } +` + +const AUTH_REGISTER = ` + mutation AuthRegister($username: String!, $password: String!, $passwordConfirmation: String!) { + authRegister(username: $username, password: $password, passwordConfirmation: $passwordConfirmation) { + user { + id + uuid + name + role { + id + name + permissions + } + createdTs + updatedTs + } + message + } + } +` + +const AUTH_LOGIN = ` + mutation AuthLogin($username: String!, $password: String!) { + authLogin(username: $username, password: $password) { + token + user { + id + name + role { + id + name + permissions + } + } + message + } + } +` + +const AUTH_LOGOUT = ` + mutation AuthLogout { + authLogout { + message + } + } +` + +const AUTH_CHANGE_PASSWORD = ` + mutation AuthChangePassword($currentPassword: String!, $newPassword: String!, $newPasswordConfirmation: String!) { + authChangePassword(currentPassword: $currentPassword, newPassword: $newPassword, newPasswordConfirmation: $newPasswordConfirmation) { + message + } + } +` + +const ADD_SITE = ` + mutation AddSite($slug: String!, $subdomain: String, $port: Int, $protocol: String, $metadataJson: String) { + addSite(slug: $slug, subdomain: $subdomain, port: $port, protocol: $protocol, metadataJson: $metadataJson) { + id + slug + subdomain + port + protocol + metadataJson + createdTs + updatedTs + } + } +` + +const UPDATE_SITE = ` + mutation UpdateSite($id: Int!, $slug: String, $subdomain: String, $port: Int, $protocol: String, $metadataJson: String) { + updateSite(id: $id, slug: $slug, subdomain: $subdomain, port: $port, protocol: $protocol, metadataJson: $metadataJson) { + id + slug + subdomain + port + protocol + metadataJson + createdTs + updatedTs + } + } +` + +const REMOVE_SITE = ` + mutation RemoveSite($siteId: Int!) { + removeSite(siteId: $siteId) { + id + slug + subdomain + port + protocol + metadataJson + createdTs + updatedTs + } + } +` + +const USERS = ` + query Users { + users { + id + uuid + name + role { + id + name + permissions + } + createdTs + updatedTs + } + } +` + +const DELETE_USER = ` + mutation DeleteUser($id: Int!) { + deleteUser(id: $id) { + success + } + } +` + +const UPDATE_USER = ` + mutation UpdateUser($id: Int!, $username: String, $roleId: Int, $password: String, $passwordConfirmation: String, $metadataJson: String) { + updateUser(id: $id, username: $username, roleId: $roleId, password: $password, passwordConfirmation: $passwordConfirmation, metadataJson: $metadataJson) { + id + uuid + name + role { + id + name + permissions + } + metadataJson + createdTs + updatedTs + } + } +` + +const ROLE_PERMISSIONS = ` + query RolePermissions { + rolePermissions + } +` + +const ROLES = ` + query Roles { + roles { + id + name + isDefault + permissions + createdTs + updatedTs + } + } +` + +const ROLE = ` + query Role($id: Int!) { + role(id: $id) { + id + name + isDefault + permissions + createdTs + updatedTs + } + } +` + +const ADD_ROLE = ` + mutation AddRole($name: String!, $permissions: [String!]!) { + addRole(name: $name, permissions: $permissions) { + id + name + isDefault + permissions + createdTs + updatedTs + } + } +` + +const UPDATE_ROLE = ` + mutation UpdateRole($id: Int!, $name: String, $permissions: [String]) { + updateRole(id: $id, name: $name, permissions: $permissions) { + id + name + isDefault + permissions + createdTs + updatedTs + } + } +` + +const SET_DEFAULT_ROLE = ` + mutation SetDefaultRole($roleId: Int!) { + setDefaultRole(roleId: $roleId) { + id + name + isDefault + permissions + createdTs + updatedTs + } + } +` + +const REMOVE_ROLE = ` + mutation RemoveRole($id: Int!) { + removeRole(id: $id) { + success + id + name + } + } +` + +// Type definitions based on schema +export interface Role { + id: string + name: string + permissions: string[] +} + +export interface User { + id: number + uuid: string + name: string + role: Role + createdTs: number + updatedTs: number +} + +export interface AuthMeResponse { + user: { + id: number + uuid: string + name: string + role: Role + createdTs: number + updatedTs: number + } + sessionIat: number + sessionExp: number +} + +export interface AuthRegisterResponse { + user: { + id: number + uuid: string + name: string + role: Role + createdTs: number + updatedTs: number + } + message: string +} + +export interface AuthLoginResponse { + token: string + user: { + id: number + name: string + role: Role + } + message: string +} + +export interface Site { + id: number + slug: string + subdomain: string + port: number + protocol: string + metadataJson?: string + createdTs: number + updatedTs: number +} + +export interface RoleWithTimestamps { + id: number + name: string + isDefault: boolean + permissions: string[] + createdTs: number + updatedTs: number +} + +export interface RoleWithDefault { + id: number + name: string + isDefault: boolean + permissions: string[] + createdTs: number + updatedTs: number +} + +// Internal wrapper functions (underscore prefix to avoid naming conflicts with store methods) +export async function _getServerTimestamp(): Promise { + const result = await client.query(GET_SERVER_TIMESTAMP, {}).toPromise() + if (result.error) throw result.error + return result.data.serverTimestamp +} + +export async function _authMe(): Promise { + // Force network fetch to ensure fresh session data + const result = await client.query(AUTH_ME, {}, { requestPolicy: 'network-only' }).toPromise() + if (result.error) throw result.error + return result.data.authMe +} + +export async function _sites(): Promise { + const result = await client.query(SITES, {}, { requestPolicy: 'network-only' }).toPromise() + if (result.error) throw result.error + return result.data.sites +} + +export async function _authRegister(username: string, password: string, passwordConfirmation: string): Promise { + const result = await client.mutation(AUTH_REGISTER, { username, password, passwordConfirmation }).toPromise() + if (result.error) throw result.error + return result.data.authRegister +} + +export async function _authLogin(username: string, password: string): Promise { + const result = await client.mutation(AUTH_LOGIN, { username, password }).toPromise() + if (result.error) throw result.error + return result.data.authLogin +} + +export async function _authLogout(): Promise<{ message: string }> { + const result = await client.mutation(AUTH_LOGOUT, {}).toPromise() + if (result.error) throw result.error + return result.data.authLogout +} + +export async function _authChangePassword(currentPassword: string, newPassword: string, newPasswordConfirmation: string): Promise<{ message: string }> { + const result = await client.mutation(AUTH_CHANGE_PASSWORD, { currentPassword, newPassword, newPasswordConfirmation }).toPromise() + if (result.error) throw result.error + return result.data.authChangePassword +} + +export async function _addSite(slug: string, subdomain?: string, port?: number, protocol?: string, metadataJson?: string): Promise { + const result = await client.mutation(ADD_SITE, { slug, subdomain, port, protocol, metadataJson }).toPromise() + if (result.error) throw result.error + return result.data.addSite +} + +export async function _updateSite(id: number, slug?: string, subdomain?: string, port?: number, protocol?: string, metadataJson?: string): Promise { + const result = await client.mutation(UPDATE_SITE, { id, slug, subdomain, port, protocol, metadataJson }).toPromise() + if (result.error) throw result.error + return result.data.updateSite +} + +export async function _removeSite(siteId: number): Promise { + const result = await client.mutation(REMOVE_SITE, { siteId }).toPromise() + if (result.error) throw result.error + return result.data.removeSite +} + +export async function _users(): Promise { + const result = await client.query(USERS, {}).toPromise() + if (result.error) throw result.error + return result.data.users +} + +export async function _deleteUser(id: number): Promise<{ success: boolean }> { + const result = await client.mutation(DELETE_USER, { id }).toPromise() + if (result.error) throw result.error + return result.data.deleteUser +} + +export async function _updateUser(id: number, username?: string, roleId?: number, password?: string, passwordConfirmation?: string, metadataJson?: string): Promise<{ id: number; uuid: string; name: string; role: Role; metadataJson?: string; createdTs: number; updatedTs: number }> { + const result = await client.mutation(UPDATE_USER, { id, username, roleId, password, passwordConfirmation, metadataJson }).toPromise() + if (result.error) throw result.error + return result.data.updateUser +} + +export async function _rolePermissions(): Promise { + const result = await client.query(ROLE_PERMISSIONS, {}).toPromise() + if (result.error) throw result.error + return result.data.rolePermissions +} + +export async function _roles(): Promise { + const result = await client.query(ROLES, {}).toPromise() + if (result.error) throw result.error + return result.data.roles +} + +export async function _role(id: number): Promise { + const result = await client.query(ROLE, { id }).toPromise() + if (result.error) throw result.error + return result.data.role +} + +export async function _addRole(name: string, permissions: string[]): Promise { + const result = await client.mutation(ADD_ROLE, { name, permissions }).toPromise() + if (result.error) throw result.error + return result.data.addRole +} + +export async function _updateRole(id: number, name?: string, permissions?: string[]): Promise { + const result = await client.mutation(UPDATE_ROLE, { id, name, permissions }).toPromise() + if (result.error) throw result.error + return result.data.updateRole +} + +export async function _setDefaultRole(roleId: number): Promise { + const result = await client.mutation(SET_DEFAULT_ROLE, { roleId }).toPromise() + if (result.error) throw result.error + return result.data.setDefaultRole +} + +export async function _removeRole(id: number): Promise<{ success: boolean; id: number; name: string }> { + const result = await client.mutation(REMOVE_ROLE, { id }).toPromise() + if (result.error) throw result.error + return result.data.removeRole +} diff --git a/src/lib/graphql-auth-client.ts b/src/lib/graphql-auth-client.ts new file mode 100644 index 0000000..9644f55 --- /dev/null +++ b/src/lib/graphql-auth-client.ts @@ -0,0 +1,26 @@ +import { createClient, cacheExchange, fetchExchange } from '@urql/vue' +import { retryExchange } from '@urql/exchange-retry' +import { getAuthApiUrl } from './utils/getAuthApiUrl' + +export const graphqlAuthClient = createClient({ + url: getAuthApiUrl(), + exchanges: [ + cacheExchange, + retryExchange({ + initialDelayMs: 1000, // Retry once per second + maxDelayMs: 1000, // Keep consistent 1-second intervals + maxNumberAttempts: 60, // Retry for 60 seconds total + retryIf: error => { + // Only retry on HTTP 502 (Bad Gateway) - deployment indicator + return error.response && error.response.status === 502 + } + }), + fetchExchange + ], + fetchOptions: { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + } + } +}) \ No newline at end of file diff --git a/src/lib/logout.ts b/src/lib/logout.ts new file mode 100644 index 0000000..5835882 --- /dev/null +++ b/src/lib/logout.ts @@ -0,0 +1,17 @@ +import { useAuthStore } from '@/stores/auth' + +export const logoutWithToast = async (router: any, toast: any) => { + const authStore = useAuthStore() + + try { + await authStore.logout() + toast.success('Logged out successfully') + + // Redirect to login page after logout + router.push({ name: 'login' }) + } catch (error) { + console.error('Logout failed:', error) + // Still redirect even if logout fails on the backend + router.push({ name: 'login' }) + } +} \ No newline at end of file diff --git a/src/lib/router.ts b/src/lib/router.ts new file mode 100644 index 0000000..93731ac --- /dev/null +++ b/src/lib/router.ts @@ -0,0 +1,98 @@ +import { createWebHistory, createRouter } from 'vue-router' + +import HomeView from '@/views/HomeView.vue' +import LoginView from '@/views/LoginView.vue' +import LoginSuccessView from '@/views/LoginSuccessView.vue' +import PasswordChangeView from '@/views/PasswordChangeView.vue' +import RegisterView from '@/views/RegisterView.vue' +import { useAuthStore } from '@/stores/auth' +import AdminApp from '@/views/admin/AdminApp.vue' +import AdminHomeView from '@/views/admin/AdminHomeView.vue' +import AdminSitesView from '@/views/admin/AdminSitesView.vue' +import AdminSitesNewView from '@/views/admin/AdminSitesNewView.vue' +import AdminSitesEditView from '@/views/admin/AdminSitesEditView.vue' +import AdminUsersView from '@/views/admin/AdminUsersView.vue' + +const routes = [ + { + path: '/', + name: 'home', + component: HomeView, + meta: { requiresAuth: true } + }, + { path: '/login', name: 'login', component: LoginView }, + { + path: '/login-success', + name: 'login-success', + component: LoginSuccessView, + meta: { requiresAuth: true } + }, + { path: '/register', name: 'register', component: RegisterView }, + { + path: '/password-change', + name: 'password-change', + component: PasswordChangeView, + meta: { requiresAuth: true } + }, + { + path: '/admin', + component: AdminApp, + meta: { requiresAuth: true, requiresAdmin: true }, + children: [ + { + path: '', + name: 'admin-home', + component: AdminHomeView, + }, + { + path: 'users', + name: 'admin-users', + component: AdminUsersView, + }, + { + path: 'sites', + name: 'admin-sites', + component: AdminSitesView, + }, + { + path: 'sites/new', + name: 'admin-sites-new', + component: AdminSitesNewView, + }, + { + path: 'sites/:id/edit', + name: 'admin-sites-edit', + component: AdminSitesEditView, + }, + ] + }, +] + +export const router = createRouter({ + history: createWebHistory(), + routes, +}) + +router.beforeEach(async (to, from, next) => { + const authStore = useAuthStore() + + if (to.meta.requiresAuth) { + await authStore.ensureSession() + if (!authStore.isAuthenticated) { + next({ + name: 'login', + query: { redirect: to.fullPath } + }) + return + } + + if (to.meta.requiresAdmin && !authStore.canAccessAdmin) { + next({ name: 'home' }) + return + } + + next() + } else { + next() + } +}) diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..c66a9d9 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,7 @@ +import type { ClassValue } from "clsx" +import { clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/src/lib/utils/getAuthApiUrl.test.ts b/src/lib/utils/getAuthApiUrl.test.ts new file mode 100644 index 0000000..f7d9c60 --- /dev/null +++ b/src/lib/utils/getAuthApiUrl.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach } from 'bun:test' +import { getAuthApiUrl } from './getAuthApiUrl' + +describe('getAuthApiUrl', () => { + beforeEach(() => { + // Clear all relevant environment variables before each test + delete process.env.VITE_DPS_AUTH_API_PROTOCOL + delete process.env.VITE_DPS_DOMAIN + delete process.env.VITE_DPS_AUTH_API_SUBDOMAIN + delete process.env.VITE_DPS_AUTH_API_PORT + delete process.env.VITE_DPS_API_PATH + }) + + it('returns correct URL when VITE_DPS_API_PATH is "api"', () => { + process.env.VITE_DPS_API_PATH = 'api' + const url = getAuthApiUrl() + expect(url).toBe('http://auth.localhost/api/graphql') + }) + + it('returns correct URL when VITE_DPS_API_PATH is empty string', () => { + process.env.VITE_DPS_API_PATH = '' + const url = getAuthApiUrl() + expect(url).toBe('http://auth.localhost/graphql') + }) + + it('returns correct URL when VITE_DPS_API_PATH is undefined', () => { + const url = getAuthApiUrl() + expect(url).toBe('http://auth.localhost/graphql') + }) + + it('returns correct URL when VITE_DPS_API_PATH contains whitespace', () => { + process.env.VITE_DPS_API_PATH = ' api ' + const url = getAuthApiUrl() + expect(url).toBe('http://auth.localhost/api/graphql') + }) + + it('returns correct URL when VITE_DPS_API_PATH is "v1/api"', () => { + process.env.VITE_DPS_API_PATH = 'v1/api' + const url = getAuthApiUrl() + expect(url).toBe('http://auth.localhost/v1/api/graphql') + }) + + it('returns correct URL with custom protocol and port', () => { + process.env.VITE_DPS_AUTH_API_PROTOCOL = 'https' + process.env.VITE_DPS_AUTH_API_PORT = '443' + process.env.VITE_DPS_API_PATH = 'api' + const url = getAuthApiUrl() + expect(url).toBe('https://auth.localhost:443/api/graphql') + }) + + it('returns correct URL with custom domain', () => { + process.env.VITE_DPS_DOMAIN = 'example.com' + process.env.VITE_DPS_API_PATH = 'api' + const url = getAuthApiUrl() + expect(url).toBe('http://auth.example.com/api/graphql') + }) + + it('returns correct URL with custom auth subdomain', () => { + process.env.VITE_DPS_AUTH_API_SUBDOMAIN = 'login' + process.env.VITE_DPS_API_PATH = 'api' + const url = getAuthApiUrl() + expect(url).toBe('http://login.localhost/api/graphql') + }) +}) diff --git a/src/lib/utils/getAuthApiUrl.ts b/src/lib/utils/getAuthApiUrl.ts new file mode 100644 index 0000000..e1dae78 --- /dev/null +++ b/src/lib/utils/getAuthApiUrl.ts @@ -0,0 +1,22 @@ +/** + * Assembles the GraphQL authentication API URL from environment variables + */ +export function getAuthApiUrl(): string { + const protocol = import.meta.env.VITE_DPS_AUTH_API_PROTOCOL || 'http' + const domain = import.meta.env.VITE_DPS_DOMAIN || 'localhost' + const authSubdomain = import.meta.env.VITE_DPS_AUTH_API_SUBDOMAIN || 'auth' + const port = import.meta.env.VITE_DPS_AUTH_API_PORT + const apiPath = import.meta.env.VITE_DPS_API_PATH + + // Build subdomain structure: auth.dps.localhost + const subdomain = `${authSubdomain}.${domain}` + + // Assemble URL with optional port + const baseUrl = `${protocol}://${subdomain}` + const portSuffix = port ? `:${port}` : '' + + // Build path: prepend "/" if apiPath exists and is not empty + const pathPrefix = apiPath && apiPath.trim() ? `/${apiPath.trim()}` : '' + + return `${baseUrl}${portSuffix}${pathPrefix}/graphql` +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 2425c0f..7392853 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,18 @@ -import { createApp } from 'vue' import './style.css' +import 'inter-ui/inter-variable.css' + +import { createApp } from 'vue' +import { createHead } from '@vueuse/head' +import { createPinia } from 'pinia' + import App from './App.vue' +import { router } from './lib/router' + +const pinia = createPinia() +const app = createApp(App) +const head = createHead() -createApp(App).mount('#app') +app.use(head) +app.use(router) +app.use(pinia) +app.mount('#app') diff --git a/src/stores/auth.ts b/src/stores/auth.ts new file mode 100644 index 0000000..acce628 --- /dev/null +++ b/src/stores/auth.ts @@ -0,0 +1,138 @@ +import { defineStore } from "pinia"; +import { ref, computed } from "vue"; +import { _authLogin, _authRegister, _authLogout, _authMe, _authChangePassword } from '@/lib/auth-wrapper'; +import type { Role, AuthMeResponse } from '@/lib/auth-wrapper'; + +export const useAuthStore = defineStore('auth', () => { + const loading = ref(false); + const sessionData = ref(null); + const loginLastError = ref(null); + const registerLastError = ref(null); + const changePasswordLastError = ref(null); + + const isAuthenticated = computed(() => sessionData.value !== null); + const isAdmin = computed(() => sessionData.value?.user?.role?.permissions?.includes('is_admin') || false); + const canAccessAdmin = computed(() => { + if (isAdmin.value) return true; + const permissions = sessionData.value?.user?.role?.permissions || []; + const adminPermissions = ['can_list_users', 'can_manage_roles', 'can_create_site', 'can_update_site', 'can_delete_site']; + return adminPermissions.some(permission => permissions.includes(permission)); + }); + + // Initialize session check immediately after me() is defined + const sessionInfoPromise = ref>(); + me() // immediately sets sessionInfoPromise (no await) + + // Replace placeholder authLogin method + async function login(username: string, password: string) { + loading.value = true; + loginLastError.value = null; + + try { + await _authLogin(username, password); + // Call me() to get complete user profile including role information + await me(); + return true; + } catch (error: any) { + loginLastError.value = error.message || 'Login failed'; + return false; + } finally { + loading.value = false; + } + } + + // Replace placeholder authRegister method + async function register(username: string, password: string, passwordConfirm: string) { + loading.value = true; + registerLastError.value = null; + + try { + const result = await _authRegister(username, password, passwordConfirm); + console.log('Registration result:', result); + // Call me() to get complete user profile + await me(); + console.log('Session data after registration:', sessionData.value); + return true; + } catch (error: any) { + registerLastError.value = error.message || 'Registration failed'; + return false; + } finally { + loading.value = false; + } + } + + // Replace placeholder authLogout method + async function logout() { + loading.value = true; + + try { + await _authLogout(); + sessionData.value = null; + // Start new session check immediately + sessionInfoPromise.value = me() + } catch (error: any) { + console.error('Logout error:', error); + } finally { + loading.value = false; + } + } + + // Do not flag this function as async, + // as it does not use await, and returns a cacheable Promise + function me (): Promise { + console.log('Fetching current user session data...'); + sessionInfoPromise.value = _authMe() + .then(authMe => { + if (authMe) { + console.log('AuthMe user data:', authMe); + sessionData.value = authMe; + } else { + sessionData.value = null; + } + }) + .catch(error => { + sessionData.value = null; + throw error; + }); + + return sessionInfoPromise.value; + } + + async function ensureSession(): Promise { + // Promise is always available, never null + return sessionInfoPromise.value; + } + + // Add changePassword method + async function changePassword(currentPassword: string, newPassword: string, newPasswordConfirmation: string) { + changePasswordLastError.value = null; + + try { + await _authChangePassword(currentPassword, newPassword, newPasswordConfirmation); + return true; + } catch (error: any) { + changePasswordLastError.value = error.message || 'Password change failed'; + return false; + } + } + + + + return { + loading, + sessionData, + loginLastError, + registerLastError, + changePasswordLastError, + isAuthenticated, + isAdmin, + canAccessAdmin, + sessionInfoPromise, + ensureSession, + login, + register, + logout, + me, + changePassword, + } +}) diff --git a/src/stores/site.ts b/src/stores/site.ts new file mode 100644 index 0000000..64b2be9 --- /dev/null +++ b/src/stores/site.ts @@ -0,0 +1,86 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import { _sites, _addSite, _updateSite, _removeSite, type Site } from '@/lib/auth-wrapper'; + +export const useSiteStore = defineStore('site', () => { + const loading = ref(false); + const sites = ref([]); + const lastError = ref(null); + + async function fetch() { + loading.value = true; + lastError.value = null; + + try { + sites.value = await _sites(); + } catch (error: any) { + lastError.value = error.message || 'Failed to fetch sites'; + } finally { + loading.value = false; + } + } + + // Load sites immediately + fetch() + + async function add(slug: string, subdomain?: string, port?: number, protocol?: string, metadataJson?: string) { + loading.value = true; + lastError.value = null; + + try { + const newSite = await _addSite(slug, subdomain, port, protocol, metadataJson); + sites.value.push(newSite); + return newSite; + } catch (error: any) { + lastError.value = error.message || 'Failed to add site'; + throw error; + } finally { + loading.value = false; + } + } + + async function update(id: number, slug?: string, subdomain?: string, port?: number, protocol?: string, metadataJson?: string) { + loading.value = true; + lastError.value = null; + + try { + const updatedSite = await _updateSite(id, slug, subdomain, port, protocol, metadataJson); + const index = sites.value.findIndex(site => site.id === id); + if (index !== -1) { + sites.value[index] = updatedSite; + } + return updatedSite; + } catch (error: any) { + lastError.value = error.message || 'Failed to update site'; + throw error; + } finally { + loading.value = false; + } + } + + async function remove(siteId: number) { + loading.value = true; + lastError.value = null; + + try { + const removedSite = await _removeSite(siteId); + sites.value = sites.value.filter(site => site.id !== siteId); + return removedSite; + } catch (error: any) { + lastError.value = error.message || 'Failed to remove site'; + throw error; + } finally { + loading.value = false; + } + } + + return { + loading, + sites, + lastError, + fetch, + add, + update, + remove, + } +}) \ No newline at end of file diff --git a/src/stores/user.ts b/src/stores/user.ts new file mode 100644 index 0000000..3a4ef12 --- /dev/null +++ b/src/stores/user.ts @@ -0,0 +1,66 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; +import { _users, _deleteUser, _updateUser, type User } from '@/lib/auth-wrapper'; + +export const useUserStore = defineStore('user', () => { + const loading = ref(false); + const users = ref([]); + const lastError = ref(null); + + async function fetch() { + loading.value = true; + lastError.value = null; + + try { + users.value = await _users(); + } catch (error: any) { + lastError.value = error.message || 'Failed to fetch users'; + } finally { + loading.value = false; + } + } + + // Load users immediately + fetch() + + async function remove(id: number) { + loading.value = true; + lastError.value = null; + + try { + const result = await _deleteUser(id); + users.value = users.value.filter(user => user.id !== id); + return result; + } catch (error: any) { + lastError.value = error.message || 'Failed to remove user'; + throw error; + } finally { + loading.value = false; + } + } + + async function update(id: number, username?: string, roleId?: number, password?: string, passwordConfirmation?: string, metadataJson?: string) { + loading.value = true; + lastError.value = null; + + try { + const updatedUser = await _updateUser(id, username, roleId, password, passwordConfirmation, metadataJson); + await fetch(); + return updatedUser; + } catch (error: any) { + lastError.value = error.message || 'Failed to update user'; + throw error; + } finally { + loading.value = false; + } + } + + return { + loading, + users, + lastError, + fetch, + remove, + update, + } +}) diff --git a/src/style.css b/src/style.css index e69de29..96a4ca6 100644 --- a/src/style.css +++ b/src/style.css @@ -0,0 +1,120 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.129 0.042 264.695); + --card: oklch(1 0 0); + --card-foreground: oklch(0.129 0.042 264.695); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.129 0.042 264.695); + --primary: oklch(0.208 0.042 265.755); + --primary-foreground: oklch(0.984 0.003 247.858); + --secondary: oklch(0.968 0.007 247.896); + --secondary-foreground: oklch(0.208 0.042 265.755); + --muted: oklch(0.968 0.007 247.896); + --muted-foreground: oklch(0.554 0.046 257.417); + --accent: oklch(0.968 0.007 247.896); + --accent-foreground: oklch(0.208 0.042 265.755); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.929 0.013 255.508); + --input: oklch(0.929 0.013 255.508); + --ring: oklch(0.704 0.04 256.788); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.984 0.003 247.858); + --sidebar-foreground: oklch(0.129 0.042 264.695); + --sidebar-primary: oklch(0.208 0.042 265.755); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.968 0.007 247.896); + --sidebar-accent-foreground: oklch(0.208 0.042 265.755); + --sidebar-border: oklch(0.929 0.013 255.508); + --sidebar-ring: oklch(0.704 0.04 256.788); +} + +.dark { + --background: oklch(0.129 0.042 264.695); + --foreground: oklch(0.984 0.003 247.858); + --card: oklch(0.208 0.042 265.755); + --card-foreground: oklch(0.984 0.003 247.858); + --popover: oklch(0.208 0.042 265.755); + --popover-foreground: oklch(0.984 0.003 247.858); + --primary: oklch(0.929 0.013 255.508); + --primary-foreground: oklch(0.208 0.042 265.755); + --secondary: oklch(0.279 0.041 260.031); + --secondary-foreground: oklch(0.984 0.003 247.858); + --muted: oklch(0.279 0.041 260.031); + --muted-foreground: oklch(0.704 0.04 256.788); + --accent: oklch(0.279 0.041 260.031); + --accent-foreground: oklch(0.984 0.003 247.858); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.551 0.027 264.364); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.208 0.042 265.755); + --sidebar-foreground: oklch(0.984 0.003 247.858); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.279 0.041 260.031); + --sidebar-accent-foreground: oklch(0.984 0.003 247.858); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.551 0.027 264.364); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue new file mode 100644 index 0000000..e1068f5 --- /dev/null +++ b/src/views/HomeView.vue @@ -0,0 +1,57 @@ + + + diff --git a/src/views/LoginSuccessView.vue b/src/views/LoginSuccessView.vue new file mode 100644 index 0000000..9c7f695 --- /dev/null +++ b/src/views/LoginSuccessView.vue @@ -0,0 +1,43 @@ + + + diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue new file mode 100644 index 0000000..0a2c90b --- /dev/null +++ b/src/views/LoginView.vue @@ -0,0 +1,116 @@ + + + diff --git a/src/views/PasswordChangeView.vue b/src/views/PasswordChangeView.vue new file mode 100644 index 0000000..2e9ed17 --- /dev/null +++ b/src/views/PasswordChangeView.vue @@ -0,0 +1,133 @@ + + + \ No newline at end of file diff --git a/src/views/RegisterView.vue b/src/views/RegisterView.vue new file mode 100644 index 0000000..9092a11 --- /dev/null +++ b/src/views/RegisterView.vue @@ -0,0 +1,133 @@ + + + diff --git a/src/views/admin/AdminApp.vue b/src/views/admin/AdminApp.vue new file mode 100644 index 0000000..a447a3f --- /dev/null +++ b/src/views/admin/AdminApp.vue @@ -0,0 +1,62 @@ + + + diff --git a/src/views/admin/AdminHomeView.vue b/src/views/admin/AdminHomeView.vue new file mode 100644 index 0000000..3e5b72b --- /dev/null +++ b/src/views/admin/AdminHomeView.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/views/admin/AdminSitesEditView.vue b/src/views/admin/AdminSitesEditView.vue new file mode 100644 index 0000000..81afa28 --- /dev/null +++ b/src/views/admin/AdminSitesEditView.vue @@ -0,0 +1,158 @@ + + + diff --git a/src/views/admin/AdminSitesNewView.vue b/src/views/admin/AdminSitesNewView.vue new file mode 100644 index 0000000..6be8dd3 --- /dev/null +++ b/src/views/admin/AdminSitesNewView.vue @@ -0,0 +1,141 @@ + + + \ No newline at end of file diff --git a/src/views/admin/AdminSitesView.vue b/src/views/admin/AdminSitesView.vue new file mode 100644 index 0000000..aa64371 --- /dev/null +++ b/src/views/admin/AdminSitesView.vue @@ -0,0 +1,138 @@ + + + diff --git a/src/views/admin/AdminUsersView.vue b/src/views/admin/AdminUsersView.vue new file mode 100644 index 0000000..feaacd0 --- /dev/null +++ b/src/views/admin/AdminUsersView.vue @@ -0,0 +1,130 @@ + + + diff --git a/tsconfig.json b/tsconfig.json index bfa0fea..dd311f4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,11 @@ // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false + "noPropertyAccessFromIndexSignature": false, + + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } } } diff --git a/vite.config.js b/vite.config.js index bbcf80c..adfd78a 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,7 +1,21 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' +import path from 'path' // https://vite.dev/config/ export default defineConfig({ - plugins: [vue()], + plugins: [ + vue(), + tailwindcss(), + ], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + port: 5000, + strictPort: true, // prevents Vite from switching to next free port + }, }) diff --git a/vue-shim.d.ts b/vue-shim.d.ts index e106577..cef1144 100644 --- a/vue-shim.d.ts +++ b/vue-shim.d.ts @@ -1,5 +1,19 @@ +/// + declare module '*.vue' { import type { DefineComponent } from 'vue'; const component: DefineComponent; export default component; } + +interface ImportMetaEnv { + readonly VITE_DPS_AUTH_API_PROTOCOL: string + readonly VITE_DPS_DOMAIN: string + readonly VITE_DPS_API_SUBDOMAIN: string + readonly VITE_DPS_AUTH_API_SUBDOMAIN: string + readonly VITE_DPS_AUTH_API_PORT: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +}