@forge-kits/nuxt
v0.1.3
Published
Nuxt composables bridge for forge-kits (FastAPI)
Readme
@forge-kits/nuxt
Nuxt module that bridges forge-kits FastAPI backend with your Nuxt app.
Two auth contexts, one module:
useForgeAuth()/useForgeAuth('client')— regular users. Login, logout, current user.useForgeAuth('guard')— RBAC context. Independent session, full RBAC viauseForgePermissions.
Installation
npm install @forge-kits/nuxt// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@forge-kits/nuxt'],
forgeApi: {
url: 'http://localhost:8000',
prefix: '/api/v1', // or false to omit
strategy: 'cookie', // 'cookie' | 'telegram'
auth: {
client: { autoFetch: true },
guard: { autoFetch: false },
},
},
})All options
| Option | Default | Description |
|---|---|---|
| url | http://localhost:8000 | Backend base URL |
| prefix | /api/v1 | API prefix prepended to every request. Set to false to omit |
| strategy | cookie | Auth strategy: cookie or telegram |
| credentials | true | Send cookies with every request |
| auth.client.autoFetch | false | Fetch client session on app start |
| auth.client.login | /auth/login | Client login endpoint |
| auth.client.logout | /auth/logout | Client logout endpoint |
| auth.client.me | /auth/me | Client current-user endpoint |
| auth.guard.autoFetch | false | Fetch guard session on app start |
| auth.guard.login | /admin/auth/login | Guard login endpoint |
| auth.guard.logout | /admin/auth/logout | Guard logout endpoint |
| auth.guard.me | /admin/auth/me | Guard current-user endpoint |
Client auth — Cookie
The server sets a signed httpOnly session cookie. Every request sends credentials: 'include' automatically. On app start the module calls /auth/me and hydrates the user state.
Login page
<!-- pages/login.vue -->
<script setup lang="ts">
const { login, isAuthenticated } = useForgeAuth()
const { form, errors, serverError, loading, submit } = useForgeForm({
email: '',
password: '',
})
if (isAuthenticated.value) navigateTo('/')
async function handleLogin() {
await submit(async (data) => {
await login(data)
navigateTo('/')
})
}
</script>
<template>
<form @submit.prevent="handleLogin">
<div>
<input v-model="form.email" type="email" placeholder="Email" />
<span v-if="errors.email">{{ errors.email }}</span>
</div>
<div>
<input v-model="form.password" type="password" placeholder="Password" />
<span v-if="errors.password">{{ errors.password }}</span>
</div>
<p v-if="serverError">{{ serverError }}</p>
<button :disabled="loading">{{ loading ? 'Signing in…' : 'Sign in' }}</button>
</form>
</template>Navbar / current user
<script setup lang="ts">
const { user, isAuthenticated, logout } = useForgeAuth()
</script>
<template>
<nav>
<template v-if="isAuthenticated">
<span>{{ user?.email }}</span>
<button @click="logout">Logout</button>
</template>
<NuxtLink v-else to="/login">Login</NuxtLink>
</nav>
</template>Client auth middleware
// middleware/auth.ts — manual
export default defineNuxtRouteMiddleware(() => {
const { isAuthenticated } = useForgeAuth()
if (!isAuthenticated.value) return navigateTo('/login')
})
// or via factory
definePageMeta({
middleware: [ForgeAuthMiddleware({ redirect: '/login' })],
})<ForgeAuth> component
Renders slot only when authenticated. Accepts role prop ('client' | 'guard', default 'client').
<ForgeAuth>
<UserDashboard />
<template #fallback>
<NuxtLink to="/login">Login</NuxtLink>
</template>
</ForgeAuth>
<!-- guard context -->
<ForgeAuth role="guard">
<AdminPanel />
<template #fallback>
<NuxtLink to="/admin/login">Admin login</NuxtLink>
</template>
</ForgeAuth>Backend:
# app/controllers/auth_controller.py
class AuthController(Controller):
prefix = "/auth"
@route.post("/login")
async def login(self, body: LoginRequest, response: Response):
user = await User.get_or_none(email=body.email)
if not user or not user.check_password(body.password):
raise HTTPException(401, "Invalid credentials")
auth.set_cookie(response, user.auth_claims())
return {"ok": True}
@route.post("/logout")
async def logout(self, response: Response):
auth.delete_cookie(response)
return {"ok": True}
@route.get("/me")
async def me(self, auth_user: CurrentUser):
user = await User.find_or_fail(int(auth_user.id))
return {"id": user.id, "email": user.email}Client auth — Telegram Mini App
Reads window.Telegram.WebApp.initData and sends it as X-Telegram-Init-Data on every request. No login/logout — the user is resolved via /auth/me on app start.
// nuxt.config.ts
forgeApi: { strategy: 'telegram' }<script setup lang="ts">
const {
user, // backend user (server-verified)
isAuthenticated,
fetchUser,
tgUser, // from initDataUnsafe — display only, untrusted
tgUserId,
tgUsername,
tgFullName,
tgPhotoUrl,
tgLanguageCode,
tgIsPremium,
tgAllowsWriteToPm,
isWebApp,
tgReady, // call WebApp.ready()
tgHaptic, // 'light' | 'medium' | 'heavy' | 'rigid' | 'soft'
tgHapticSuccess,
} = useForgeAuth()
onMounted(tgReady)
</script>
<template>
<div v-if="isAuthenticated">
<img v-if="tgPhotoUrl" :src="tgPhotoUrl" />
<p>{{ tgFullName }}</p>
<p v-if="tgIsPremium">⭐ Premium</p>
</div>
</template>Local development — paste a real initData string to .env:
VITE_TELEGRAM_INIT_DATA=user=%7B%22id%22%3A...&hash=abc123<ForgeTg> component
Renders slot only inside a Telegram Mini App (isWebApp === true).
<ForgeTg>
<TelegramSpecificUI />
<template #fallback>
<p>Open in Telegram to continue.</p>
</template>
</ForgeTg>Guard auth & RBAC
Guard has its own independent session (forge_guard state, separate endpoints). The guard's /me response must include permissions and roles arrays — the module uses them for all RBAC checks.
Guard login page
<!-- pages/admin/login.vue -->
<script setup lang="ts">
const { login, isAuthenticated } = useForgeAuth('guard')
const { form, errors, serverError, loading, submit } = useForgeForm({
email: '',
password: '',
})
if (isAuthenticated.value) navigateTo('/admin/dashboard')
async function handleLogin() {
await submit(async (data) => {
await login(data)
navigateTo('/admin/dashboard')
})
}
</script>
<template>
<form @submit.prevent="handleLogin">
<div>
<input v-model="form.email" type="email" placeholder="Email" />
<span v-if="errors.email">{{ errors.email }}</span>
</div>
<div>
<input v-model="form.password" type="password" placeholder="Password" />
<span v-if="errors.password">{{ errors.password }}</span>
</div>
<p v-if="serverError">{{ serverError }}</p>
<button :disabled="loading">{{ loading ? 'Signing in…' : 'Admin sign in' }}</button>
</form>
</template>Guard auth middleware
// via factory (recommended)
definePageMeta({
middleware: [ForgeAuthMiddleware({ role: 'guard', redirect: '/admin/login' })],
})
// or manual
export default defineNuxtRouteMiddleware(() => {
const { isAuthenticated } = useForgeAuth('guard')
if (!isAuthenticated.value) return navigateTo('/admin/login')
})RBAC in templates — <ForgeCan> / <ForgeRole>
<!-- any permission from the list -->
<ForgeCan perm="edit:posts">
<button>Edit</button>
</ForgeCan>
<!-- must have ALL permissions -->
<ForgeCan :perm="['edit:posts', 'publish:posts']" :all="true">
<PublishPanel />
</ForgeCan>
<!-- role-based with fallback -->
<ForgeRole role="admin">
<AdminPanel />
<template #fallback>
<p>Admins only.</p>
</template>
</ForgeRole>
<!-- must have ALL roles -->
<ForgeRole :role="['admin', 'editor']" :all="true">
<SuperPanel />
</ForgeRole>| Prop | Type | Default | Description |
|---|---|---|---|
| perm / role | string \| string[] | — | Permission(s) or role(s) to check |
| all | boolean | false | Require all (AND) instead of any (OR) |
RBAC in logic — useForgePermissions
<script setup lang="ts">
const { can, canAll, hasRole, hasAllRoles, permissions, roles } = useForgePermissions()
</script>
<template>
<button v-if="can('edit:posts')">Edit</button>
<button v-if="hasRole('admin')">Delete</button>
</template>| Method | Returns true when |
|---|---|
| can(...perms) | guard has any of the permissions |
| canAll(...perms) | guard has all permissions |
| hasRole(...roles) | guard has any of the roles |
| hasAllRoles(...roles) | guard has all roles |
RBAC route middleware
// pages/admin/posts/[id]/edit.vue
definePageMeta({
middleware: [
'admin-auth',
PermissionMiddleware('edit:posts', { redirect: '/admin/403' }),
],
})
// require ALL permissions
definePageMeta({
middleware: [PermissionAllMiddleware(['edit:posts', 'publish:posts'], { redirect: '/admin/403' })],
})
// role-based
definePageMeta({
middleware: [RoleMiddleware('editor', { redirect: '/admin/403' })],
})
// require ALL roles
definePageMeta({
middleware: [RoleAllMiddleware(['admin', 'editor'], { redirect: '/admin/403' })],
})Without redirect option — throws 403 Forbidden. With redirect — calls navigateTo(redirect).
Backend:
@route.get("/me")
async def me(self, auth_user: CurrentUser):
admin = await Admin.find_or_fail(int(auth_user.id))
return {
"id": admin.id,
"email": admin.email,
"permissions": await admin.get_all_permissions(),
"roles": await admin.get_role_names(),
}API calls — useForgeApi
Typed wrapper around $fetch. Automatically attaches baseURL, cookies/Telegram header, and credentials.
<script setup lang="ts">
interface Post { id: number; title: string; body: string }
const api = useForgeApi()
const posts = await api.get<Post[]>('/posts', { params: { page: 1, search: 'nuxt' } })
const post = await api.post<Post>('/posts', { title: 'Hello', body: '...' })
await api.patch<Post>(`/posts/${post.id}`, { title: 'Updated' })
await api.put<Post>(`/posts/${post.id}`, { title: 'Replaced', body: '...' })
await api.delete(`/posts/${post.id}`)
</script>Forms — useForgeForm
Automatically maps FastAPI 422 Pydantic validation errors to field-level errors. Non-field errors go to serverError. Re-throws unknown errors so you can handle them upstream.
<script setup lang="ts">
const api = useForgeApi()
const { form, errors, serverError, loading, clearErrors, submit } = useForgeForm({
title: '',
body: '',
tags: '',
})
async function handleSubmit() {
await submit(async (data) => {
await api.post('/posts', data)
navigateTo('/posts')
})
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div>
<input v-model="form.title" placeholder="Title" />
<span v-if="errors.title">{{ errors.title }}</span>
</div>
<div>
<textarea v-model="form.body" placeholder="Body" />
<span v-if="errors.body">{{ errors.body }}</span>
</div>
<p v-if="serverError" class="error">{{ serverError }}</p>
<button :disabled="loading">{{ loading ? 'Saving…' : 'Save' }}</button>
</form>
</template>| Return | Type | Description |
|---|---|---|
| form | reactive<T> | Two-way bound form data |
| errors | Ref<Record<string, string>> | Field errors from Pydantic 422 |
| serverError | Ref<string \| null> | Non-field error from detail string |
| loading | Ref<boolean> | true while submit is in-flight |
| clearErrors() | () => void | Reset all errors manually |
| submit(fn) | (fn) => Promise<void> | Run fn, catch and map backend errors |
Pagination — useForgePagination
Fetches paginated data on mount. Backend must return the standard forge-kits envelope.
<script setup lang="ts">
interface Post { id: number; title: string }
const { data, meta, loading, error, page, nextPage, prevPage, goToPage } =
useForgePagination<Post>('/posts', { perPage: 20 })
</script>
<template>
<div v-if="loading">Loading…</div>
<ul v-else>
<li v-for="post in data" :key="post.id">{{ post.title }}</li>
</ul>
<div>
<button :disabled="page <= 1" @click="prevPage">← Prev</button>
<span>{{ page }} / {{ meta?.last_page }}</span>
<button :disabled="!meta || page >= meta.last_page" @click="nextPage">Next →</button>
</div>
</template>| Option | Type | Default | Description |
|---|---|---|---|
| perPage | number | backend default | Items per page |
| immediate | boolean | true | Fetch on mount |
PaginationMeta: { current_page, per_page, total, last_page, from, to }
Backend envelope: { data: T[], meta: PaginationMeta, links: { prev, next } }
File uploads — useForgeUpload
Uses XMLHttpRequest for real-time progress tracking. Cookies and Telegram header are sent automatically.
<script setup lang="ts">
const { progress, loading, error, result, upload, reset } = useForgeUpload('/files/upload')
async function handleFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const res = await upload(file)
console.log('URL:', res.url)
}
// with extra form fields
async function handleAvatar(file: File) {
await upload(file, { category: 'avatars' })
}
</script>
<template>
<input type="file" @change="handleFile" />
<div v-if="loading">
<progress :value="progress" max="100" />
<span>{{ progress }}%</span>
</div>
<p v-if="error">{{ error }}</p>
<img v-if="result?.url" :src="result.url" />
<button v-if="result" @click="reset">Upload another</button>
</template>| Return | Type | Description |
|---|---|---|
| progress | Ref<number> | 0–100, updated in real time |
| loading | Ref<boolean> | true while uploading |
| error | Ref<string \| null> | Error message if upload failed |
| result | Ref<{ url, path?, ...} \| null> | Server response |
| upload(file, extra?) | Promise<UploadResponse> | Start upload |
| reset() | () => void | Clear state |
API reference
Composables
| Composable | Signature | Returns |
|---|---|---|
| useForgeAuth | (role?: 'client' \| 'guard') | user, isAuthenticated, login, logout, fetchUser, initData, initDataUnsafe, tgUser, tgUserId, tgUsername, tgFullName, tgPhotoUrl, tgLanguageCode, tgIsPremium, tgAllowsWriteToPm, isWebApp, tgReady, tgHaptic, tgHapticSuccess |
| useForgePermissions | () | permissions, roles, can, canAll, hasRole, hasAllRoles |
| useForgeApi | () | get, post, patch, put, delete |
| useForgeForm | <T>(initial: T) | form, errors, serverError, loading, clearErrors, submit |
| useForgePagination | <T>(url, opts?) | data, meta, links, loading, error, page, perPage, fetch, nextPage, prevPage, goToPage |
| useForgeUpload | (path: string) | progress, loading, error, result, upload, reset |
Components
| Component | Props | Description |
|---|---|---|
| <ForgeAuth> | role?: 'client' \| 'guard' | Renders slot if authenticated in the given context |
| <ForgeTg> | — | Renders slot only inside a Telegram Mini App |
| <ForgeCan> | perm: string\|string[], all?: boolean | Renders slot if guard has the permission(s) |
| <ForgeRole> | role: string\|string[], all?: boolean | Renders slot if guard has the role(s) |
All components accept a #fallback slot rendered when access is denied.
Middleware factories
| Factory | Signature | Description |
|---|---|---|
| ForgeAuthMiddleware | (opts?) | Requires authenticated session |
| PermissionMiddleware | (perm, opts?) | Any of the given permissions |
| PermissionAllMiddleware | (perm, opts?) | All of the given permissions |
| RoleMiddleware | (role, opts?) | Any of the given roles |
| RoleAllMiddleware | (role, opts?) | All of the given roles |
opts for ForgeAuthMiddleware: { role?: 'client' | 'guard', redirect?: string }
opts for RBAC middleware: { redirect?: string }
