@innertia-solutions/nuxt-app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/composables/useApi.js +60 -0
- package/composables/useAuth.js +68 -0
- package/composables/useContext.js +44 -0
- package/composables/usePermissions.js +19 -0
- package/middleware/auth.ts +9 -0
- package/middleware/guest.ts +9 -0
- package/nuxt.config.ts +4 -0
- package/package.json +29 -0
- package/plugins/api-auth.client.ts +13 -0
- package/plugins/auth-init.client.ts +8 -0
- package/stores/auth.js +116 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// useRequestInterceptors auto-imported from nuxt-core
|
|
2
|
+
// useAuthStore auto-imported from this package
|
|
3
|
+
|
|
4
|
+
export function useApi() {
|
|
5
|
+
const config = useRuntimeConfig()
|
|
6
|
+
const baseUrl = config.public.apiBaseUrl || '/api'
|
|
7
|
+
const loginPath = config.public.loginPath || '/login'
|
|
8
|
+
|
|
9
|
+
const { run, add } = useRequestInterceptors()
|
|
10
|
+
|
|
11
|
+
async function makeRequest(method, path, body = null, options = {}) {
|
|
12
|
+
const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' }
|
|
13
|
+
run(headers, options)
|
|
14
|
+
|
|
15
|
+
const cleanPath = path.startsWith('/') ? path.slice(1) : path
|
|
16
|
+
const url = `${baseUrl}/${cleanPath}`
|
|
17
|
+
|
|
18
|
+
const fetchOptions = { method, headers }
|
|
19
|
+
if (body !== null) fetchOptions.body = JSON.stringify(body)
|
|
20
|
+
|
|
21
|
+
const response = await fetch(url, fetchOptions)
|
|
22
|
+
|
|
23
|
+
if (response.status === 401) {
|
|
24
|
+
const authStore = useAuthStore()
|
|
25
|
+
authStore.logout()
|
|
26
|
+
await navigateTo(loginPath)
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const contentType = response.headers.get('content-type') ?? ''
|
|
31
|
+
const data = contentType.includes('application/json') ? await response.json() : await response.text()
|
|
32
|
+
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
const err = new Error(`API error ${response.status}`)
|
|
35
|
+
err.status = response.status
|
|
36
|
+
err.data = data
|
|
37
|
+
throw err
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return data
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const get = (path, options = {}) => makeRequest('GET', path, null, options)
|
|
44
|
+
const post = (path, body, options = {}) => makeRequest('POST', path, body, options)
|
|
45
|
+
const put = (path, body, options = {}) => makeRequest('PUT', path, body, options)
|
|
46
|
+
const patch = (path, body, options = {}) => makeRequest('PATCH', path, body, options)
|
|
47
|
+
const del = (path, options = {}) => makeRequest('DELETE', path, null, options)
|
|
48
|
+
|
|
49
|
+
// *Sync aliases — same methods, named for clarity in call sites
|
|
50
|
+
const getSync = get
|
|
51
|
+
const postSync = post
|
|
52
|
+
const putSync = put
|
|
53
|
+
const patchSync = patch
|
|
54
|
+
const deleteSync = del
|
|
55
|
+
|
|
56
|
+
/** Shortcut to add an interceptor from a composable or plugin */
|
|
57
|
+
const addInterceptor = (fn) => add(fn)
|
|
58
|
+
|
|
59
|
+
return { get, post, put, patch, delete: del, getSync, postSync, putSync, patchSync, deleteSync, addInterceptor }
|
|
60
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// useAuthStore, useApi auto-imported
|
|
2
|
+
|
|
3
|
+
export function useAuth() {
|
|
4
|
+
const authStore = useAuthStore()
|
|
5
|
+
const api = useApi()
|
|
6
|
+
const config = useRuntimeConfig()
|
|
7
|
+
const loginPath = config.public.loginPath || '/login'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Standard login (email + password).
|
|
11
|
+
* context: role/area slug used in the API path (e.g. 'admin', 'technician').
|
|
12
|
+
*/
|
|
13
|
+
async function performLogin(context, email, password, remember = false) {
|
|
14
|
+
authStore.rememberUser = remember
|
|
15
|
+
const data = await api.post(`${context}/auth/login`, { email, password })
|
|
16
|
+
authStore.saveToken(data.access_token)
|
|
17
|
+
authStore.setCurrentContext(context)
|
|
18
|
+
await fetchMe()
|
|
19
|
+
return data
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Load current user, permissions, and available contexts from the API.
|
|
24
|
+
* Called after login and after context switch.
|
|
25
|
+
*/
|
|
26
|
+
async function fetchMe() {
|
|
27
|
+
const data = await api.get('auth/me')
|
|
28
|
+
authStore.saveUser(data.user)
|
|
29
|
+
authStore.savePermissions(data.permissions ?? [])
|
|
30
|
+
authStore.availableContexts = data.availableContexts ?? []
|
|
31
|
+
return data
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Logout: best-effort POST to backend, then clear local state and redirect.
|
|
36
|
+
*/
|
|
37
|
+
async function logout() {
|
|
38
|
+
try {
|
|
39
|
+
await api.post('auth/logout', {})
|
|
40
|
+
} catch {
|
|
41
|
+
// best-effort — ignore network failures
|
|
42
|
+
}
|
|
43
|
+
authStore.logout()
|
|
44
|
+
await navigateTo(loginPath)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Get the OAuth redirect URL for a provider.
|
|
49
|
+
* Returns the URL string from the backend.
|
|
50
|
+
*/
|
|
51
|
+
async function getOauthRedirectUrl(context, provider) {
|
|
52
|
+
const data = await api.get(`${context}/auth/oauth/${provider}/redirect`)
|
|
53
|
+
return data.url
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Handle OAuth callback. Same success path as performLogin.
|
|
58
|
+
*/
|
|
59
|
+
async function handleOauthCallback(context, provider, code) {
|
|
60
|
+
const data = await api.post(`${context}/auth/oauth/${provider}/callback`, { code })
|
|
61
|
+
authStore.saveToken(data.access_token)
|
|
62
|
+
authStore.setCurrentContext(context)
|
|
63
|
+
await fetchMe()
|
|
64
|
+
return data
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return { performLogin, fetchMe, logout, getOauthRedirectUrl, handleOauthCallback }
|
|
68
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// useAuthStore, useApi, useAuth auto-imported
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
|
|
4
|
+
export function useContext() {
|
|
5
|
+
const authStore = useAuthStore()
|
|
6
|
+
const api = useApi()
|
|
7
|
+
const { fetchMe } = useAuth()
|
|
8
|
+
|
|
9
|
+
const currentContext = computed(() => authStore.currentContext)
|
|
10
|
+
const availableContexts = computed(() => authStore.availableContexts)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Check whether user has permission to switch to targetContext.
|
|
14
|
+
* Returns:
|
|
15
|
+
* { success: false, reason: 'no_permission' } — user cannot switch
|
|
16
|
+
* { success: true, requiresConfirmation: true } — show confirmation UI
|
|
17
|
+
*/
|
|
18
|
+
async function switchContext(targetContext) {
|
|
19
|
+
const data = await api.get(`auth/context/${targetContext}/check`)
|
|
20
|
+
if (!data.hasAccess) {
|
|
21
|
+
return { success: false, reason: 'no_permission' }
|
|
22
|
+
}
|
|
23
|
+
return { success: true, requiresConfirmation: true }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Execute the context switch after user confirmation.
|
|
28
|
+
* Updates store and reloads permissions via fetchMe.
|
|
29
|
+
*/
|
|
30
|
+
async function confirmSwitch(targetContext) {
|
|
31
|
+
authStore.setCurrentContext(targetContext)
|
|
32
|
+
await fetchMe()
|
|
33
|
+
return { success: true }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Quick synchronous check — is this context in the available list?
|
|
38
|
+
*/
|
|
39
|
+
function hasAccessToContext(context) {
|
|
40
|
+
return authStore.availableContexts.includes(context)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { currentContext, availableContexts, switchContext, confirmSwitch, hasAccessToContext }
|
|
44
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// useAuthStore auto-imported
|
|
2
|
+
|
|
3
|
+
export function usePermissions() {
|
|
4
|
+
const authStore = useAuthStore()
|
|
5
|
+
|
|
6
|
+
/** Check a single permission string */
|
|
7
|
+
const can = (permission) => authStore.permissions.includes(permission)
|
|
8
|
+
|
|
9
|
+
/** Check a single role string */
|
|
10
|
+
const hasRole = (role) => authStore.user?.roles?.includes(role) ?? false
|
|
11
|
+
|
|
12
|
+
/** True if user has at least one of the given permissions */
|
|
13
|
+
const hasAny = (permissions) => permissions.some(p => authStore.permissions.includes(p))
|
|
14
|
+
|
|
15
|
+
/** True if user has all of the given permissions */
|
|
16
|
+
const hasAll = (permissions) => permissions.every(p => authStore.permissions.includes(p))
|
|
17
|
+
|
|
18
|
+
return { can, hasRole, hasAny, hasAll }
|
|
19
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Redirect unauthenticated users to login.
|
|
2
|
+
// useAuthStore auto-imported from nuxt-app stores.
|
|
3
|
+
export default defineNuxtRouteMiddleware(() => {
|
|
4
|
+
const authStore = useAuthStore()
|
|
5
|
+
const config = useRuntimeConfig()
|
|
6
|
+
if (!authStore.isAuthenticated()) {
|
|
7
|
+
return navigateTo(config.public.loginPath || '/login')
|
|
8
|
+
}
|
|
9
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Redirect already-authenticated users away from guest-only pages (login, register).
|
|
2
|
+
// useAuthStore auto-imported from nuxt-app stores.
|
|
3
|
+
export default defineNuxtRouteMiddleware(() => {
|
|
4
|
+
const authStore = useAuthStore()
|
|
5
|
+
const config = useRuntimeConfig()
|
|
6
|
+
if (authStore.isAuthenticated()) {
|
|
7
|
+
return navigateTo(config.public.homePath || '/')
|
|
8
|
+
}
|
|
9
|
+
})
|
package/nuxt.config.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@innertia-solutions/nuxt-app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Innertia Solutions — Nuxt app layer: auth, context, permissions, API client",
|
|
5
|
+
"keywords": ["nuxt", "vue", "pinia", "auth", "permissions", "context"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/innertia-solutions/innertia-nuxt"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"main": "./nuxt.config.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./nuxt.config.ts"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"nuxt": ">=4.0.0",
|
|
20
|
+
"vue": ">=3.5.0"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@innertia-solutions/nuxt-core": "^0.1.3"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"nuxt": "^4.4.2",
|
|
27
|
+
"vue": "^3.5.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Registers the Authorization header interceptor into the shared registry.
|
|
2
|
+
// Runs only on the client (cookie-based token is not available on the server).
|
|
3
|
+
export default defineNuxtPlugin(() => {
|
|
4
|
+
const { add } = useRequestInterceptors()
|
|
5
|
+
const authStore = useAuthStore()
|
|
6
|
+
|
|
7
|
+
add((headers: Record<string, string>, options: Record<string, unknown>) => {
|
|
8
|
+
if (options.useToken !== false) {
|
|
9
|
+
const token = authStore.getToken()
|
|
10
|
+
if (token) headers['Authorization'] = `Bearer ${token}`
|
|
11
|
+
}
|
|
12
|
+
})
|
|
13
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// On app boot: if the stored token exists but is expired, clear auth state silently.
|
|
2
|
+
// This prevents stale tokens from reaching the API.
|
|
3
|
+
export default defineNuxtPlugin(() => {
|
|
4
|
+
const authStore = useAuthStore()
|
|
5
|
+
if (authStore.token && !authStore.isAuthenticated()) {
|
|
6
|
+
authStore.logout()
|
|
7
|
+
}
|
|
8
|
+
})
|
package/stores/auth.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
|
|
3
|
+
// ─── cookie helpers ────────────────────────────────────────────────────────
|
|
4
|
+
// Never call useCookie() inside Pinia actions — it throws outside Vue setup.
|
|
5
|
+
// Pattern: try useCookie (works in setup), catch → document.cookie fallback.
|
|
6
|
+
|
|
7
|
+
function _setCookie(name, value, maxAgeSeconds) {
|
|
8
|
+
try {
|
|
9
|
+
const isDev = import.meta.env?.DEV ?? false
|
|
10
|
+
const cookie = useCookie(name, { maxAge: maxAgeSeconds, sameSite: 'lax', secure: !isDev })
|
|
11
|
+
cookie.value = typeof value === 'object' ? JSON.stringify(value) : value
|
|
12
|
+
} catch {
|
|
13
|
+
if (import.meta.client) {
|
|
14
|
+
const expires = maxAgeSeconds ? `; Max-Age=${Math.floor(maxAgeSeconds)}` : ''
|
|
15
|
+
const val = typeof value === 'object' ? JSON.stringify(value) : value
|
|
16
|
+
document.cookie = `${name}=${encodeURIComponent(val)}${expires}; path=/; SameSite=Lax`
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function _getCookie(name) {
|
|
22
|
+
try {
|
|
23
|
+
const cookie = useCookie(name)
|
|
24
|
+
return cookie.value ?? null
|
|
25
|
+
} catch {
|
|
26
|
+
if (import.meta.client) {
|
|
27
|
+
const match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)'))
|
|
28
|
+
return match ? decodeURIComponent(match[1]) : null
|
|
29
|
+
}
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _deleteCookie(name) {
|
|
35
|
+
try {
|
|
36
|
+
const cookie = useCookie(name)
|
|
37
|
+
cookie.value = null
|
|
38
|
+
} catch {
|
|
39
|
+
if (import.meta.client) {
|
|
40
|
+
document.cookie = `${name}=; Max-Age=0; path=/`
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ─── JWT decode (no lib) ───────────────────────────────────────────────────
|
|
46
|
+
function _decodeJwtExpiry(token) {
|
|
47
|
+
try {
|
|
48
|
+
const payload = JSON.parse(atob(token.split('.')[1]))
|
|
49
|
+
return payload.exp ?? null
|
|
50
|
+
} catch {
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── store ─────────────────────────────────────────────────────────────────
|
|
56
|
+
export const useAuthStore = defineStore('auth', {
|
|
57
|
+
state: () => ({
|
|
58
|
+
token: null,
|
|
59
|
+
user: null,
|
|
60
|
+
currentContext: null,
|
|
61
|
+
availableContexts: [],
|
|
62
|
+
permissions: [],
|
|
63
|
+
rememberUser: false,
|
|
64
|
+
}),
|
|
65
|
+
|
|
66
|
+
persist: {
|
|
67
|
+
pick: ['token', 'user', 'currentContext', 'availableContexts'],
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
actions: {
|
|
71
|
+
// ── token ──────────────────────────────────────────────────────────────
|
|
72
|
+
saveToken(token) {
|
|
73
|
+
this.token = token
|
|
74
|
+
_setCookie('auth_token', token, 60 * 60 * 24 * 7) // 7 days
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
getToken() {
|
|
78
|
+
return this.token ?? _getCookie('auth_token')
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
isAuthenticated() {
|
|
82
|
+
const token = this.getToken()
|
|
83
|
+
if (!token) return false
|
|
84
|
+
const exp = _decodeJwtExpiry(token)
|
|
85
|
+
if (exp === null) return true // non-JWT or no expiry claim → treat as valid
|
|
86
|
+
return Date.now() / 1000 < exp
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
// ── user ───────────────────────────────────────────────────────────────
|
|
90
|
+
saveUser(user) {
|
|
91
|
+
this.user = user
|
|
92
|
+
_setCookie('auth_user', user, 60 * 60 * 24 * 7)
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
// ── context ────────────────────────────────────────────────────────────
|
|
96
|
+
setCurrentContext(context) {
|
|
97
|
+
this.currentContext = context
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
// ── permissions ────────────────────────────────────────────────────────
|
|
101
|
+
savePermissions(permissions) {
|
|
102
|
+
this.permissions = permissions ?? []
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
// ── logout ─────────────────────────────────────────────────────────────
|
|
106
|
+
logout() {
|
|
107
|
+
this.token = null
|
|
108
|
+
this.user = null
|
|
109
|
+
this.currentContext = null
|
|
110
|
+
this.availableContexts = []
|
|
111
|
+
this.permissions = []
|
|
112
|
+
_deleteCookie('auth_token')
|
|
113
|
+
_deleteCookie('auth_user')
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
})
|