blacksmith-cli 0.1.1
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/README.md +210 -0
- package/bin/blacksmith.js +20 -0
- package/dist/index.js +4404 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
- package/src/templates/backend/.env.example.hbs +10 -0
- package/src/templates/backend/apps/__init__.py.hbs +0 -0
- package/src/templates/backend/apps/users/__init__.py.hbs +0 -0
- package/src/templates/backend/apps/users/admin.py.hbs +26 -0
- package/src/templates/backend/apps/users/managers.py.hbs +25 -0
- package/src/templates/backend/apps/users/models.py.hbs +25 -0
- package/src/templates/backend/apps/users/serializers.py.hbs +94 -0
- package/src/templates/backend/apps/users/tests.py.hbs +47 -0
- package/src/templates/backend/apps/users/urls.py.hbs +10 -0
- package/src/templates/backend/apps/users/views.py.hbs +175 -0
- package/src/templates/backend/config/__init__.py.hbs +0 -0
- package/src/templates/backend/config/asgi.py.hbs +9 -0
- package/src/templates/backend/config/settings/__init__.py.hbs +13 -0
- package/src/templates/backend/config/settings/base.py.hbs +117 -0
- package/src/templates/backend/config/settings/development.py.hbs +19 -0
- package/src/templates/backend/config/settings/production.py.hbs +31 -0
- package/src/templates/backend/config/urls.py.hbs +26 -0
- package/src/templates/backend/config/wsgi.py.hbs +9 -0
- package/src/templates/backend/manage.py.hbs +22 -0
- package/src/templates/backend/requirements.txt.hbs +7 -0
- package/src/templates/frontend/.env.hbs +1 -0
- package/src/templates/frontend/index.html.hbs +13 -0
- package/src/templates/frontend/openapi-ts.config.ts.hbs +29 -0
- package/src/templates/frontend/package.json.hbs +44 -0
- package/src/templates/frontend/postcss.config.js.hbs +6 -0
- package/src/templates/frontend/src/api/client.ts.hbs +110 -0
- package/src/templates/frontend/src/api/generated/.gitkeep +0 -0
- package/src/templates/frontend/src/api/generated/client.gen.ts +13 -0
- package/src/templates/frontend/src/api/query-client.ts.hbs +22 -0
- package/src/templates/frontend/src/app.tsx.hbs +30 -0
- package/src/templates/frontend/src/features/auth/adapter.ts.hbs +198 -0
- package/src/templates/frontend/src/features/auth/components/auth-provider.tsx.hbs +32 -0
- package/src/templates/frontend/src/features/auth/hooks/use-auth.ts.hbs +27 -0
- package/src/templates/frontend/src/features/auth/index.ts.hbs +3 -0
- package/src/templates/frontend/src/features/auth/pages/forgot-password-page.tsx.hbs +37 -0
- package/src/templates/frontend/src/features/auth/pages/login-page.tsx.hbs +36 -0
- package/src/templates/frontend/src/features/auth/pages/register-page.tsx.hbs +36 -0
- package/src/templates/frontend/src/features/auth/pages/reset-password-page.tsx.hbs +41 -0
- package/src/templates/frontend/src/features/auth/routes.tsx.hbs +13 -0
- package/src/templates/frontend/src/main.tsx.hbs +10 -0
- package/src/templates/frontend/src/pages/dashboard/components/quick-start-card.tsx.hbs +36 -0
- package/src/templates/frontend/src/pages/dashboard/components/stack-cards.tsx.hbs +69 -0
- package/src/templates/frontend/src/pages/dashboard/components/welcome-header.tsx.hbs +14 -0
- package/src/templates/frontend/src/pages/dashboard/dashboard.tsx.hbs +21 -0
- package/src/templates/frontend/src/pages/dashboard/index.ts.hbs +1 -0
- package/src/templates/frontend/src/pages/dashboard/routes.tsx.hbs +7 -0
- package/src/templates/frontend/src/pages/home/components/features-grid.tsx.hbs +88 -0
- package/src/templates/frontend/src/pages/home/components/getting-started.tsx.hbs +88 -0
- package/src/templates/frontend/src/pages/home/components/hero-section.tsx.hbs +47 -0
- package/src/templates/frontend/src/pages/home/components/resources-section.tsx.hbs +34 -0
- package/src/templates/frontend/src/pages/home/home.tsx.hbs +20 -0
- package/src/templates/frontend/src/pages/home/index.ts.hbs +1 -0
- package/src/templates/frontend/src/pages/home/routes.tsx.hbs +7 -0
- package/src/templates/frontend/src/router/auth-guard.tsx.hbs +57 -0
- package/src/templates/frontend/src/router/error-boundary.tsx.hbs +61 -0
- package/src/templates/frontend/src/router/index.tsx.hbs +12 -0
- package/src/templates/frontend/src/router/layouts/auth-layout.tsx.hbs +68 -0
- package/src/templates/frontend/src/router/layouts/main-layout.tsx.hbs +137 -0
- package/src/templates/frontend/src/router/paths.ts.hbs +38 -0
- package/src/templates/frontend/src/router/routes.tsx.hbs +64 -0
- package/src/templates/frontend/src/shared/components/loading-spinner.tsx.hbs +20 -0
- package/src/templates/frontend/src/shared/components/not-found-page.tsx.hbs +31 -0
- package/src/templates/frontend/src/shared/hooks/api-error.ts.hbs +147 -0
- package/src/templates/frontend/src/shared/hooks/use-api-mutation.ts.hbs +88 -0
- package/src/templates/frontend/src/shared/hooks/use-api-query.ts.hbs +66 -0
- package/src/templates/frontend/src/shared/hooks/use-debounce.ts.hbs +10 -0
- package/src/templates/frontend/src/styles/globals.css.hbs +62 -0
- package/src/templates/frontend/src/vite-env.d.ts.hbs +1 -0
- package/src/templates/frontend/tailwind.config.js.hbs +73 -0
- package/src/templates/frontend/tsconfig.app.json.hbs +25 -0
- package/src/templates/frontend/tsconfig.json.hbs +7 -0
- package/src/templates/frontend/tsconfig.node.json.hbs +18 -0
- package/src/templates/frontend/vite.config.ts.hbs +21 -0
- package/src/templates/resource/backend/__init__.py.hbs +0 -0
- package/src/templates/resource/backend/admin.py.hbs +10 -0
- package/src/templates/resource/backend/models.py.hbs +24 -0
- package/src/templates/resource/backend/serializers.py.hbs +21 -0
- package/src/templates/resource/backend/tests.py.hbs +35 -0
- package/src/templates/resource/backend/urls.py.hbs +10 -0
- package/src/templates/resource/backend/views.py.hbs +32 -0
- package/src/templates/resource/frontend/components/{{kebab}}-card.tsx.hbs +39 -0
- package/src/templates/resource/frontend/components/{{kebab}}-form.tsx.hbs +106 -0
- package/src/templates/resource/frontend/components/{{kebab}}-list.tsx.hbs +49 -0
- package/src/templates/resource/frontend/hooks/use-{{kebabs}}-query.ts.hbs +35 -0
- package/src/templates/resource/frontend/hooks/use-{{kebab}}-mutations.ts.hbs +39 -0
- package/src/templates/resource/frontend/index.ts.hbs +6 -0
- package/src/templates/resource/frontend/pages/{{kebabs}}-page.tsx.hbs +33 -0
- package/src/templates/resource/frontend/pages/{{kebab}}-detail-page.tsx.hbs +96 -0
- package/src/templates/resource/frontend/routes.tsx.hbs +15 -0
- package/src/templates/resource/pages/components/{{kebab}}-card.tsx.hbs +39 -0
- package/src/templates/resource/pages/components/{{kebab}}-form.tsx.hbs +106 -0
- package/src/templates/resource/pages/components/{{kebab}}-list.tsx.hbs +49 -0
- package/src/templates/resource/pages/hooks/use-{{kebabs}}-query.ts.hbs +35 -0
- package/src/templates/resource/pages/hooks/use-{{kebab}}-mutations.ts.hbs +39 -0
- package/src/templates/resource/pages/index.ts.hbs +6 -0
- package/src/templates/resource/pages/routes.tsx.hbs +15 -0
- package/src/templates/resource/pages/{{kebabs}}-page.tsx.hbs +33 -0
- package/src/templates/resource/pages/{{kebab}}-detail-page.tsx.hbs +96 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
URL configuration for {{projectName}}.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from django.contrib import admin
|
|
6
|
+
from django.urls import path, include
|
|
7
|
+
from drf_spectacular.views import (
|
|
8
|
+
SpectacularAPIView,
|
|
9
|
+
SpectacularSwaggerView,
|
|
10
|
+
SpectacularRedocView,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
urlpatterns = [
|
|
14
|
+
path('admin/', admin.site.urls),
|
|
15
|
+
|
|
16
|
+
# OpenAPI schema & documentation
|
|
17
|
+
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
|
18
|
+
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
|
19
|
+
path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
|
|
20
|
+
|
|
21
|
+
# Auth endpoints
|
|
22
|
+
path('api/auth/', include('apps.users.urls')),
|
|
23
|
+
|
|
24
|
+
# Resource endpoints (auto-registered by blacksmith make:resource)
|
|
25
|
+
# blacksmith:urls
|
|
26
|
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Django's command-line utility for administrative tasks."""
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
"""Run administrative tasks."""
|
|
9
|
+
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
|
10
|
+
try:
|
|
11
|
+
from django.core.management import execute_from_command_line
|
|
12
|
+
except ImportError as exc:
|
|
13
|
+
raise ImportError(
|
|
14
|
+
"Couldn't import Django. Are you sure it's installed and "
|
|
15
|
+
"available on your PYTHONPATH environment variable? Did you "
|
|
16
|
+
"forget to activate a virtual environment?"
|
|
17
|
+
) from exc
|
|
18
|
+
execute_from_command_line(sys.argv)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
if __name__ == '__main__':
|
|
22
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VITE_API_URL=http://localhost:{{backendPort}}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>{{projectName}}</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { defineConfig } from '@hey-api/openapi-ts'
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
input: {
|
|
5
|
+
path: 'http://localhost:{{backendPort}}/api/schema/',
|
|
6
|
+
},
|
|
7
|
+
output: {
|
|
8
|
+
path: './src/api/generated',
|
|
9
|
+
},
|
|
10
|
+
plugins: [
|
|
11
|
+
{
|
|
12
|
+
name: '@hey-api/typescript',
|
|
13
|
+
enums: 'javascript',
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
name: '@hey-api/client-fetch',
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: '@hey-api/sdk',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: '@tanstack/react-query',
|
|
23
|
+
queryOptions: true,
|
|
24
|
+
infiniteQueryOptions: true,
|
|
25
|
+
mutationOptions: true,
|
|
26
|
+
},
|
|
27
|
+
'zod',
|
|
28
|
+
],
|
|
29
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{projectName}}-frontend",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "tsc -b && vite build",
|
|
9
|
+
"lint": "eslint .",
|
|
10
|
+
"preview": "vite preview",
|
|
11
|
+
"openapi-ts": "openapi-ts"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@blacksmith-ui/auth": "^0.1.0",
|
|
15
|
+
"@blacksmith-ui/forms": "^0.1.0",
|
|
16
|
+
"@blacksmith-ui/hooks": "^0.1.0",
|
|
17
|
+
"@blacksmith-ui/react": "^0.1.2",
|
|
18
|
+
"@hey-api/client-fetch": "^0.9.0",
|
|
19
|
+
"@hookform/resolvers": "^5.0.0",
|
|
20
|
+
"@tanstack/react-query": "^5.90.0",
|
|
21
|
+
"@tanstack/react-query-devtools": "^5.90.0",
|
|
22
|
+
"clsx": "^2.1.1",
|
|
23
|
+
"lucide-react": "^0.400.0",
|
|
24
|
+
"react": "^19.1.0",
|
|
25
|
+
"react-dom": "^19.1.0",
|
|
26
|
+
"react-error-boundary": "^5.0.0",
|
|
27
|
+
"react-hook-form": "^7.55.0",
|
|
28
|
+
"react-router-dom": "^7.6.0",
|
|
29
|
+
"tailwind-merge": "^3.0.0",
|
|
30
|
+
"tailwindcss": "^3.4.0",
|
|
31
|
+
"zod": "^3.24.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@hey-api/openapi-ts": "^0.93.0",
|
|
35
|
+
"@types/react": "^19.1.0",
|
|
36
|
+
"@types/react-dom": "^19.1.0",
|
|
37
|
+
"@vitejs/plugin-react": "^5.0.0",
|
|
38
|
+
"autoprefixer": "^10.4.0",
|
|
39
|
+
"postcss": "^8.4.0",
|
|
40
|
+
"tailwindcss-animate": "^1.0.7",
|
|
41
|
+
"typescript": "~5.8.0",
|
|
42
|
+
"vite": "^6.3.0"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API Client Configuration
|
|
3
|
+
*
|
|
4
|
+
* Configures the @hey-api/client-fetch client with:
|
|
5
|
+
* - Base URL from environment
|
|
6
|
+
* - JWT auth interceptor
|
|
7
|
+
* - CSRF token interceptor (for Django)
|
|
8
|
+
* - 401 token refresh interceptor
|
|
9
|
+
*
|
|
10
|
+
* Generated by Blacksmith. You own this file — customize as needed.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createClient } from '@hey-api/client-fetch'
|
|
14
|
+
|
|
15
|
+
// Create the client directly (no dependency on generated files)
|
|
16
|
+
export const client = createClient({
|
|
17
|
+
baseUrl: import.meta.env.VITE_API_URL || 'http://localhost:{{backendPort}}',
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// In-memory token storage (more secure than localStorage)
|
|
21
|
+
let accessToken: string | null = null
|
|
22
|
+
let refreshToken: string | null = null
|
|
23
|
+
|
|
24
|
+
export function setTokens(access: string | null, refresh?: string | null) {
|
|
25
|
+
accessToken = access
|
|
26
|
+
if (refresh !== undefined) {
|
|
27
|
+
refreshToken = refresh
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getAccessToken(): string | null {
|
|
32
|
+
return accessToken
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getRefreshToken(): string | null {
|
|
36
|
+
return refreshToken
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function clearTokens() {
|
|
40
|
+
accessToken = null
|
|
41
|
+
refreshToken = null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Auth interceptor — attach JWT to every request
|
|
45
|
+
client.interceptors.request.use((request) => {
|
|
46
|
+
if (accessToken) {
|
|
47
|
+
request.headers.set('Authorization', `Bearer ${accessToken}`)
|
|
48
|
+
}
|
|
49
|
+
return request
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// CSRF interceptor — Django requires this for non-GET requests
|
|
53
|
+
client.interceptors.request.use((request) => {
|
|
54
|
+
const csrfToken = document.cookie
|
|
55
|
+
.split('; ')
|
|
56
|
+
.find((row) => row.startsWith('csrftoken='))
|
|
57
|
+
?.split('=')[1]
|
|
58
|
+
|
|
59
|
+
if (csrfToken) {
|
|
60
|
+
request.headers.set('X-CSRFToken', csrfToken)
|
|
61
|
+
}
|
|
62
|
+
return request
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// 401 interceptor — attempt token refresh, then retry
|
|
66
|
+
let isRefreshing = false
|
|
67
|
+
let refreshPromise: Promise<boolean> | null = null
|
|
68
|
+
|
|
69
|
+
async function attemptTokenRefresh(): Promise<boolean> {
|
|
70
|
+
if (!refreshToken) return false
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const response = await fetch(
|
|
74
|
+
`${import.meta.env.VITE_API_URL || 'http://localhost:{{backendPort}}'}/api/auth/refresh/`,
|
|
75
|
+
{
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: { 'Content-Type': 'application/json' },
|
|
78
|
+
body: JSON.stringify({ refresh: refreshToken }),
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if (!response.ok) return false
|
|
83
|
+
|
|
84
|
+
const data = await response.json()
|
|
85
|
+
setTokens(data.access, data.refresh)
|
|
86
|
+
return true
|
|
87
|
+
} catch {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
client.interceptors.response.use(async (response) => {
|
|
93
|
+
if (response.status === 401 && !response.url.includes('/api/auth/refresh/')) {
|
|
94
|
+
if (!isRefreshing) {
|
|
95
|
+
isRefreshing = true
|
|
96
|
+
refreshPromise = attemptTokenRefresh().finally(() => {
|
|
97
|
+
isRefreshing = false
|
|
98
|
+
refreshPromise = null
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const refreshed = await refreshPromise
|
|
103
|
+
if (!refreshed) {
|
|
104
|
+
clearTokens()
|
|
105
|
+
window.dispatchEvent(new CustomEvent('auth:logout'))
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return response
|
|
109
|
+
})
|
|
110
|
+
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-generated API Client
|
|
3
|
+
*
|
|
4
|
+
* This is a stub file that allows the app to boot before
|
|
5
|
+
* the first OpenAPI sync. Run `blacksmith sync` or `blacksmith dev`
|
|
6
|
+
* to generate the real client from your Django API schema.
|
|
7
|
+
*
|
|
8
|
+
* Generated by Blacksmith. This file will be overwritten by openapi-ts.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createClient } from '@hey-api/client-fetch'
|
|
12
|
+
|
|
13
|
+
export const client = createClient()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Query Client Configuration
|
|
3
|
+
*
|
|
4
|
+
* Centralized QueryClient with sensible defaults.
|
|
5
|
+
* Generated by Blacksmith. You own this file — customize as needed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { QueryClient } from '@tanstack/react-query'
|
|
9
|
+
|
|
10
|
+
export const queryClient = new QueryClient({
|
|
11
|
+
defaultOptions: {
|
|
12
|
+
queries: {
|
|
13
|
+
staleTime: 5 * 60 * 1000, // 5 minutes before data is considered stale
|
|
14
|
+
gcTime: 30 * 60 * 1000, // 30 minutes in garbage collection cache
|
|
15
|
+
retry: 2, // Retry failed queries twice
|
|
16
|
+
refetchOnWindowFocus: true,
|
|
17
|
+
},
|
|
18
|
+
mutations: {
|
|
19
|
+
retry: 0, // Never retry mutations
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root Application Component
|
|
3
|
+
*
|
|
4
|
+
* Composes all providers and the router.
|
|
5
|
+
* Generated by Blacksmith. You own this file — customize as needed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
9
|
+
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
|
10
|
+
import { RouterProvider } from 'react-router-dom'
|
|
11
|
+
import { ThemeProvider } from '@blacksmith-ui/react'
|
|
12
|
+
import { queryClient } from '@/api/query-client'
|
|
13
|
+
import { AuthProvider } from '@/features/auth/components/auth-provider'
|
|
14
|
+
import { router } from '@/router'
|
|
15
|
+
|
|
16
|
+
// Initialize API client (registers interceptors)
|
|
17
|
+
import '@/api/client'
|
|
18
|
+
|
|
19
|
+
export function App() {
|
|
20
|
+
return (
|
|
21
|
+
<ThemeProvider defaultMode="system" storageKey="{{projectName}}-theme">
|
|
22
|
+
<QueryClientProvider client={queryClient}>
|
|
23
|
+
<AuthProvider>
|
|
24
|
+
<RouterProvider router={router} />
|
|
25
|
+
</AuthProvider>
|
|
26
|
+
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
|
|
27
|
+
</QueryClientProvider>
|
|
28
|
+
</ThemeProvider>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blacksmith Auth Adapter
|
|
3
|
+
*
|
|
4
|
+
* Connects @blacksmith-ui/auth to the Django JWT backend.
|
|
5
|
+
* Implements the AuthAdapter interface using the generated API client.
|
|
6
|
+
*
|
|
7
|
+
* Generated by Blacksmith. You own this file — customize as needed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AuthAdapter, AuthUser, AuthResult, AuthError, SocialProvider } from '@blacksmith-ui/auth'
|
|
11
|
+
import { setTokens, getAccessToken, clearTokens } from '@/api/client'
|
|
12
|
+
import { parseApiError } from '@/shared/hooks/api-error'
|
|
13
|
+
|
|
14
|
+
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:{{backendPort}}'
|
|
15
|
+
|
|
16
|
+
// Internal state
|
|
17
|
+
let currentUser: AuthUser | null = null
|
|
18
|
+
let authListeners: Array<(user: AuthUser | null) => void> = []
|
|
19
|
+
|
|
20
|
+
function notifyListeners(user: AuthUser | null) {
|
|
21
|
+
currentUser = user
|
|
22
|
+
authListeners.forEach((cb) => cb(user))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function mapDjangoUser(data: any): AuthUser {
|
|
26
|
+
return {
|
|
27
|
+
id: String(data.id),
|
|
28
|
+
email: data.email,
|
|
29
|
+
displayName: data.first_name
|
|
30
|
+
? `${data.first_name}${data.last_name ? ' ' + data.last_name : ''}`
|
|
31
|
+
: null,
|
|
32
|
+
photoURL: null,
|
|
33
|
+
emailVerified: true,
|
|
34
|
+
providerId: 'password',
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toAuthError(error: unknown): AuthError {
|
|
39
|
+
const parsed = parseApiError(error)
|
|
40
|
+
|
|
41
|
+
// If there are field-level errors, include them in the message
|
|
42
|
+
const fieldMessages = Object.entries(parsed.fieldErrors)
|
|
43
|
+
.map(([field, msgs]) => `${field}: ${msgs.join(', ')}`)
|
|
44
|
+
.join('. ')
|
|
45
|
+
|
|
46
|
+
const message = fieldMessages
|
|
47
|
+
? `${parsed.message} ${fieldMessages}`
|
|
48
|
+
: parsed.message
|
|
49
|
+
|
|
50
|
+
return { code: `auth/${parsed.status || 'error'}`, message }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function apiFetch(path: string, options: RequestInit = {}): Promise<any> {
|
|
54
|
+
const response = await fetch(`${API_URL}${path}`, {
|
|
55
|
+
headers: {
|
|
56
|
+
'Content-Type': 'application/json',
|
|
57
|
+
...(getAccessToken() ? { Authorization: `Bearer ${getAccessToken()}` } : {}),
|
|
58
|
+
},
|
|
59
|
+
...options,
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const data = await response.json().catch(() => null)
|
|
63
|
+
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
throw { status: response.status, error: data || { detail: response.statusText } }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return data
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function fetchCurrentUser(): Promise<AuthUser> {
|
|
72
|
+
const data = await apiFetch('/api/auth/me/')
|
|
73
|
+
return mapDjangoUser(data)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createBlacksmithAuthAdapter(): AuthAdapter {
|
|
77
|
+
// Try to restore session on init
|
|
78
|
+
;(async () => {
|
|
79
|
+
try {
|
|
80
|
+
if (getAccessToken()) {
|
|
81
|
+
const user = await fetchCurrentUser()
|
|
82
|
+
notifyListeners(user)
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
clearTokens()
|
|
86
|
+
notifyListeners(null)
|
|
87
|
+
}
|
|
88
|
+
})()
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
async signInWithEmail(email: string, password: string): Promise<AuthResult> {
|
|
92
|
+
try {
|
|
93
|
+
const data = await apiFetch('/api/auth/login/', {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
body: JSON.stringify({ email, password }),
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
setTokens(data.access, data.refresh)
|
|
99
|
+
const user = await fetchCurrentUser()
|
|
100
|
+
notifyListeners(user)
|
|
101
|
+
|
|
102
|
+
return { success: true, user }
|
|
103
|
+
} catch (error: unknown) {
|
|
104
|
+
return { success: false, error: toAuthError(error) }
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async signUpWithEmail(
|
|
109
|
+
email: string,
|
|
110
|
+
password: string,
|
|
111
|
+
displayName?: string
|
|
112
|
+
): Promise<AuthResult> {
|
|
113
|
+
try {
|
|
114
|
+
const data = await apiFetch('/api/auth/register/', {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
email,
|
|
118
|
+
password,
|
|
119
|
+
password_confirm: password,
|
|
120
|
+
first_name: displayName || '',
|
|
121
|
+
}),
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
setTokens(data.access, data.refresh)
|
|
125
|
+
const user = await fetchCurrentUser()
|
|
126
|
+
notifyListeners(user)
|
|
127
|
+
|
|
128
|
+
return { success: true, user }
|
|
129
|
+
} catch (error: unknown) {
|
|
130
|
+
return { success: false, error: toAuthError(error) }
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
async signInWithSocial(_provider: SocialProvider): Promise<AuthResult> {
|
|
135
|
+
return {
|
|
136
|
+
success: false,
|
|
137
|
+
error: {
|
|
138
|
+
code: 'auth/unsupported',
|
|
139
|
+
message: 'Social login is not yet configured. Implement OAuth on the Django backend.',
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
async sendPasswordResetEmail(email: string) {
|
|
145
|
+
try {
|
|
146
|
+
await apiFetch('/api/auth/forgot-password/', {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
body: JSON.stringify({ email }),
|
|
149
|
+
})
|
|
150
|
+
return { success: true as const }
|
|
151
|
+
} catch (error: unknown) {
|
|
152
|
+
return { success: false as const, error: toAuthError(error) }
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
async confirmPasswordReset(code: string, newPassword: string) {
|
|
157
|
+
try {
|
|
158
|
+
await apiFetch('/api/auth/reset-password/', {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
body: JSON.stringify({
|
|
161
|
+
token: code,
|
|
162
|
+
password: newPassword,
|
|
163
|
+
password_confirm: newPassword,
|
|
164
|
+
}),
|
|
165
|
+
})
|
|
166
|
+
return { success: true as const }
|
|
167
|
+
} catch (error: unknown) {
|
|
168
|
+
return { success: false as const, error: toAuthError(error) }
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
async signOut(): Promise<void> {
|
|
173
|
+
try {
|
|
174
|
+
await apiFetch('/api/auth/logout/', {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
})
|
|
177
|
+
} catch {
|
|
178
|
+
// Ignore errors on logout
|
|
179
|
+
}
|
|
180
|
+
clearTokens()
|
|
181
|
+
notifyListeners(null)
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
getCurrentUser(): AuthUser | null {
|
|
185
|
+
return currentUser
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
onAuthStateChanged(callback: (user: AuthUser | null) => void): () => void {
|
|
189
|
+
authListeners.push(callback)
|
|
190
|
+
// Fire immediately with current state
|
|
191
|
+
callback(currentUser)
|
|
192
|
+
|
|
193
|
+
return () => {
|
|
194
|
+
authListeners = authListeners.filter((cb) => cb !== callback)
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Provider
|
|
3
|
+
*
|
|
4
|
+
* Wraps the app with @blacksmith-ui/auth's AuthProvider configured
|
|
5
|
+
* with the Django JWT adapter.
|
|
6
|
+
*
|
|
7
|
+
* Generated by Blacksmith. You own this file — customize as needed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { AuthProvider as BlacksmithAuthProvider } from '@blacksmith-ui/auth'
|
|
11
|
+
import { createBlacksmithAuthAdapter } from '../adapter'
|
|
12
|
+
import type { ReactNode } from 'react'
|
|
13
|
+
|
|
14
|
+
const adapter = createBlacksmithAuthAdapter()
|
|
15
|
+
|
|
16
|
+
interface Props {
|
|
17
|
+
children: ReactNode
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function AuthProvider({ children }: Props) {
|
|
21
|
+
return (
|
|
22
|
+
<BlacksmithAuthProvider
|
|
23
|
+
config=\{{
|
|
24
|
+
adapter,
|
|
25
|
+
// Enable social providers by adding them here:
|
|
26
|
+
// socialProviders: ['google', 'github'],
|
|
27
|
+
}}
|
|
28
|
+
>
|
|
29
|
+
{children}
|
|
30
|
+
</BlacksmithAuthProvider>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth Hook
|
|
3
|
+
*
|
|
4
|
+
* Re-exports useAuth from @blacksmith-ui/auth for convenience.
|
|
5
|
+
* Import from here so your app has a single auth import path.
|
|
6
|
+
*
|
|
7
|
+
* Generated by Blacksmith. You own this file — customize as needed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useAuth as useBlacksmithAuth } from '@blacksmith-ui/auth'
|
|
11
|
+
|
|
12
|
+
export function useAuth() {
|
|
13
|
+
const auth = useBlacksmithAuth()
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
user: auth.user,
|
|
17
|
+
isLoading: auth.loading,
|
|
18
|
+
isAuthenticated: !!auth.user,
|
|
19
|
+
error: auth.error,
|
|
20
|
+
login: auth.signInWithEmail,
|
|
21
|
+
register: auth.signUpWithEmail,
|
|
22
|
+
logout: auth.signOut,
|
|
23
|
+
sendPasswordResetEmail: auth.sendPasswordResetEmail,
|
|
24
|
+
confirmPasswordReset: auth.confirmPasswordReset,
|
|
25
|
+
socialProviders: auth.socialProviders,
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forgot Password Page
|
|
3
|
+
*
|
|
4
|
+
* Uses @blacksmith-ui/auth ForgotPasswordForm connected to Django backend.
|
|
5
|
+
* Generated by Blacksmith. You own this file — customize as needed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ForgotPasswordForm } from '@blacksmith-ui/auth'
|
|
9
|
+
import { useNavigate } from 'react-router-dom'
|
|
10
|
+
import { useAuth } from '../hooks/use-auth'
|
|
11
|
+
import { useState } from 'react'
|
|
12
|
+
import { Path } from '@/router/paths'
|
|
13
|
+
|
|
14
|
+
export default function ForgotPasswordPage() {
|
|
15
|
+
const navigate = useNavigate()
|
|
16
|
+
const { sendPasswordResetEmail, error } = useAuth()
|
|
17
|
+
const [loading, setLoading] = useState(false)
|
|
18
|
+
|
|
19
|
+
const handleSubmit = async (data: { email: string }) => {
|
|
20
|
+
setLoading(true)
|
|
21
|
+
try {
|
|
22
|
+
await sendPasswordResetEmail(data.email)
|
|
23
|
+
// Show success regardless (prevents email enumeration)
|
|
24
|
+
} finally {
|
|
25
|
+
setLoading(false)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<ForgotPasswordForm
|
|
31
|
+
onSubmit={handleSubmit}
|
|
32
|
+
onLoginClick={() => navigate(Path.Login)}
|
|
33
|
+
error={error}
|
|
34
|
+
loading={loading}
|
|
35
|
+
/>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Login Page
|
|
3
|
+
*
|
|
4
|
+
* Uses @blacksmith-ui/auth LoginForm connected to Django JWT backend.
|
|
5
|
+
* Generated by Blacksmith. You own this file — customize as needed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { LoginForm } from '@blacksmith-ui/auth'
|
|
9
|
+
import { useNavigate, useSearchParams } from 'react-router-dom'
|
|
10
|
+
import { useAuth } from '../hooks/use-auth'
|
|
11
|
+
import { Path } from '@/router/paths'
|
|
12
|
+
|
|
13
|
+
export default function LoginPage() {
|
|
14
|
+
const navigate = useNavigate()
|
|
15
|
+
const [searchParams] = useSearchParams()
|
|
16
|
+
const { login, error, isLoading } = useAuth()
|
|
17
|
+
|
|
18
|
+
const redirectTo = searchParams.get('redirect') || Path.Home
|
|
19
|
+
|
|
20
|
+
const handleSubmit = async (data: { email: string; password: string }) => {
|
|
21
|
+
const result = await login(data.email, data.password)
|
|
22
|
+
if (result.success) {
|
|
23
|
+
navigate(redirectTo, { replace: true })
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<LoginForm
|
|
29
|
+
onSubmit={handleSubmit}
|
|
30
|
+
onRegisterClick={() => navigate(Path.Register)}
|
|
31
|
+
onForgotPasswordClick={() => navigate(Path.ForgotPassword)}
|
|
32
|
+
error={error}
|
|
33
|
+
loading={isLoading}
|
|
34
|
+
/>
|
|
35
|
+
)
|
|
36
|
+
}
|