astris-python 0.1.0__py3-none-any.whl

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.
astris/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from astris.config import Settings
2
+ from astris.kernel import Astris
3
+
4
+ __all__ = [
5
+ "Astris",
6
+ "Settings",
7
+ ]
Binary file
@@ -0,0 +1,29 @@
1
+ from astris.auth.session import (
2
+ AuthUser,
3
+ AuthUserId,
4
+ auth_required,
5
+ get_auth_user,
6
+ get_user_id,
7
+ guest_required,
8
+ hash_password,
9
+ is_authenticated,
10
+ login_user,
11
+ logout_user,
12
+ verify_and_update_password,
13
+ verify_password,
14
+ )
15
+
16
+ __all__ = [
17
+ "AuthUser",
18
+ "AuthUserId",
19
+ "auth_required",
20
+ "get_auth_user",
21
+ "get_user_id",
22
+ "guest_required",
23
+ "hash_password",
24
+ "is_authenticated",
25
+ "login_user",
26
+ "logout_user",
27
+ "verify_and_update_password",
28
+ "verify_password",
29
+ ]
@@ -0,0 +1,512 @@
1
+ from pathlib import Path
2
+
3
+ AUTH_MODEL_TEMPLATE = """from astris.database import Field, SQLModel
4
+
5
+
6
+ class UserBase(SQLModel):
7
+ name: str = Field(index=True)
8
+ email: str = Field(unique=True, index=True)
9
+
10
+
11
+ class User(UserBase, table=True):
12
+ id: int | None = Field(default=None, primary_key=True)
13
+ hashed_password: str = Field()
14
+
15
+
16
+ class UserLogin(SQLModel):
17
+ email: str
18
+ password: str
19
+ remember: bool = False
20
+
21
+
22
+ class UserRegister(SQLModel):
23
+ name: str
24
+ email: str
25
+ password: str
26
+ password_confirmation: str
27
+ """
28
+
29
+ AUTH_SERVICE_TEMPLATE = """from app.modules.auth.auth_model import User, UserRegister
30
+ from astris.auth import hash_password, verify_and_update_password
31
+ from astris.database import Session, select
32
+
33
+
34
+ class AuthService:
35
+ @staticmethod
36
+ def authenticate(session: Session, email: str, password: str) -> User | None:
37
+ user = session.exec(select(User).where(User.email == email)).first()
38
+ if not user:
39
+ return None
40
+ valid, updated_hash = verify_and_update_password(password, user.hashed_password)
41
+ if not valid:
42
+ return None
43
+ if updated_hash:
44
+ user.hashed_password = updated_hash
45
+ session.add(user)
46
+ session.commit()
47
+ session.refresh(user)
48
+ return user
49
+
50
+ @staticmethod
51
+ def register(session: Session, data: UserRegister) -> User:
52
+ hashed = hash_password(data.password)
53
+ user = User(
54
+ name=data.name,
55
+ email=data.email,
56
+ hashed_password=hashed,
57
+ )
58
+ session.add(user)
59
+ session.commit()
60
+ session.refresh(user)
61
+ return user
62
+
63
+ @staticmethod
64
+ def email_exists(session: Session, email: str) -> bool:
65
+ return session.exec(select(User).where(User.email == email)).first() is not None
66
+ """
67
+
68
+ AUTH_CONTROLLER_TEMPLATE = """from app.modules.auth.auth_model import UserLogin, UserRegister
69
+ from app.modules.auth.auth_service import AuthService
70
+ from astris.auth import auth_required, guest_required, login_user, logout_user
71
+ from astris.database import DatabaseSession
72
+ from astris.http import HTTPException, RedirectResponse, Request
73
+ from astris.inertia import InertiaResponse, flash
74
+ from astris.routing import Controller
75
+
76
+ controller = Controller(tags=["Auth"])
77
+
78
+
79
+ @controller.get("/login", dependencies=[guest_required])
80
+ async def login_page(request: Request) -> InertiaResponse:
81
+ return InertiaResponse(request, "Auth/Login")
82
+
83
+
84
+ @controller.post("/login", dependencies=[guest_required])
85
+ async def login(
86
+ request: Request, data: UserLogin, db: DatabaseSession
87
+ ) -> RedirectResponse:
88
+ user = AuthService.authenticate(db, data.email, data.password)
89
+ if not user:
90
+ raise HTTPException(
91
+ status_code=422,
92
+ detail={"error": "These credentials do not match our records."},
93
+ )
94
+
95
+ login_user(request, user)
96
+ flash(request, "success", f"Welcome back, {user.name}!")
97
+ return RedirectResponse(url="/dashboard", status_code=303)
98
+
99
+
100
+ @controller.get("/register", dependencies=[guest_required])
101
+ async def register_page(request: Request) -> InertiaResponse:
102
+ return InertiaResponse(request, "Auth/Register")
103
+
104
+
105
+ @controller.post("/register", dependencies=[guest_required])
106
+ async def register(
107
+ request: Request, data: UserRegister, db: DatabaseSession
108
+ ) -> RedirectResponse:
109
+ if data.password != data.password_confirmation:
110
+ raise HTTPException(
111
+ status_code=422,
112
+ detail={
113
+ "password_confirmation": "The password confirmation does not match."
114
+ },
115
+ )
116
+
117
+ if AuthService.email_exists(db, data.email):
118
+ raise HTTPException(
119
+ status_code=422,
120
+ detail={"email": "An account with this email already exists."},
121
+ )
122
+
123
+ user = AuthService.register(db, data)
124
+ login_user(request, user)
125
+ flash(request, "success", f"Welcome to Astris, {user.name}!")
126
+ return RedirectResponse(url="/dashboard", status_code=303)
127
+
128
+
129
+ @controller.post("/logout")
130
+ async def logout(request: Request) -> RedirectResponse:
131
+ logout_user(request)
132
+ flash(request, "info", "You have been logged out.")
133
+ return RedirectResponse(url="/login", status_code=303)
134
+
135
+
136
+ @controller.get("/dashboard", dependencies=[auth_required])
137
+ async def dashboard(request: Request) -> InertiaResponse:
138
+ return InertiaResponse(request, "Dashboard")
139
+ """
140
+
141
+ ASTRIS_LOGO_VUE_TEMPLATE = """<template>
142
+ <svg
143
+ viewBox="0 0 792 792"
144
+ fill="currentColor"
145
+ xmlns="http://www.w3.org/2000/svg"
146
+ >
147
+ <path d="m50 757h88.8l37.8-92.3c-31.4-1.6-59-6.3-82.4-14.2z"/>
148
+ <path d="m596.3 550.8q-11 6.5-22.3 12.6l79.1 193.6h88.9l-98.1-236.5q-22.4 15.6-47.6 30.3z"/>
149
+ <path d="m210.1 582.6l185.9-454.7 147.5 360.9c25-14.4 48-29.9 68.2-46l-169.2-407.8h-93l-221.6 534.1c21.2 8.3 49.5 12.9 82.2 13.5z"/>
150
+ <path fill-rule="evenodd" d="m372.5 466.8l23.5 76.4 23.5-76.4 69-23.5-68.8-23.8-23.7-75.8-23.7 75.8-68.8 23.8 69 23.5z"/>
151
+ <path d="m756.2 309.9c-18.3-50.9-96.5-72.1-201.4-61.6 81.6-3.1 141.7 15.8 156.8 58 26.6 74.2-96.3 192.3-273.9 256.1-177.7 63.7-343.3 55.2-370-19-15.5-43.4 19.7-99.6 87.1-151.5-90.4 60.9-137.6 131.1-118.9 183.2 29.4 82 214.3 90.6 413.1 19.3 198.8-71.3 336.6-202.5 307.2-284.5z"/>
152
+ <path d="m90 533.2c2.1 6 5.3 11.5 9.4 16.6q-2.3-3.9-3.9-8.2c-12.7-35.5 18.2-82.5 77.1-127.1l12.8-31c-71.7 50.7-110.7 107.1-95.4 149.7z"/>
153
+ <path d="m437 275.7c-30 6.8-61.1 15.9-92.6 27.2q-0.3 0.1-0.6 0.2l-10.1 24.7q8.5-3.3 17.3-6.4c31.8-11.4 63.2-20.7 93.3-27.8l-7.4-17.9z"/>
154
+ <path d="m688.7 323.7q1.4 4.1 2.1 8.4c0-6.6-1.1-12.9-3.2-18.9-13.1-36.3-63.2-53.7-132.2-52.9l7 16.8c66.6-1.8 114.4 13.4 126.3 46.6z"/>
155
+ </svg>
156
+ </template>
157
+ """
158
+
159
+ VUE_LOGIN_TEMPLATE = """<script setup lang="ts">
160
+ import { useForm, Link } from '@inertiajs/vue3'
161
+ import AstrisLogo from '../../Components/AstrisLogo.vue'
162
+
163
+ const form = useForm({
164
+ email: '',
165
+ password: '',
166
+ remember: false,
167
+ })
168
+
169
+ const submit = () => {
170
+ form.post('/login', {
171
+ onFinish: () => form.reset('password'),
172
+ })
173
+ }
174
+ </script>
175
+
176
+ <template>
177
+ <div class="min-h-screen bg-slate-950 flex flex-col justify-center py-12 sm:px-6 lg:px-8 text-slate-100 font-sans relative selection:bg-sky-500 selection:text-white">
178
+ <!-- Subtle Background Glow -->
179
+ <div class="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[300px] bg-sky-500/10 blur-[100px] rounded-full pointer-events-none -z-10"></div>
180
+
181
+ <div class="sm:mx-auto sm:w-full sm:max-w-md">
182
+ <div class="flex justify-center">
183
+ <Link href="/" class="p-3 rounded-2xl bg-slate-900/80 border border-slate-800 backdrop-blur shadow-xl hover:border-sky-500/40 transition duration-200">
184
+ <AstrisLogo class="w-10 h-10 text-sky-400" />
185
+ </Link>
186
+ </div>
187
+ <h2 class="mt-4 text-center text-3xl font-extrabold tracking-tight text-white">
188
+ Sign in to your account
189
+ </h2>
190
+ <p class="mt-2 text-center text-sm text-slate-400">
191
+ Or
192
+ <Link href="/register" class="font-medium text-sky-400 hover:text-sky-300 underline underline-offset-4 transition">
193
+ create a new account
194
+ </Link>
195
+ </p>
196
+ </div>
197
+
198
+ <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
199
+ <div class="bg-slate-900/80 backdrop-blur border border-slate-800 py-8 px-6 shadow-2xl rounded-2xl sm:px-10">
200
+ <form @submit.prevent="submit" class="space-y-6">
201
+ <div
202
+ v-if="form.errors.error"
203
+ class="p-3 bg-rose-500/10 border border-rose-500/30 rounded-xl text-sm text-rose-400 flex items-center gap-2"
204
+ >
205
+ <svg class="w-4 h-4 shrink-0 text-rose-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
206
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
207
+ </svg>
208
+ <span>{{ form.errors.error }}</span>
209
+ </div>
210
+
211
+ <div>
212
+ <label for="email" class="block text-sm font-medium text-slate-300">Email address</label>
213
+ <div class="mt-1">
214
+ <input
215
+ id="email"
216
+ v-model="form.email"
217
+ type="email"
218
+ autocomplete="email"
219
+ required
220
+ class="appearance-none block w-full px-3.5 py-2.5 border border-slate-700 rounded-xl shadow-sm placeholder-slate-500 bg-slate-800/80 text-white focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition text-sm"
221
+ placeholder="you@example.com"
222
+ />
223
+ </div>
224
+ <p v-if="form.errors.email" class="mt-2 text-sm text-rose-400">{{ form.errors.email }}</p>
225
+ </div>
226
+
227
+ <div>
228
+ <label for="password" class="block text-sm font-medium text-slate-300">Password</label>
229
+ <div class="mt-1">
230
+ <input
231
+ id="password"
232
+ v-model="form.password"
233
+ type="password"
234
+ autocomplete="current-password"
235
+ required
236
+ class="appearance-none block w-full px-3.5 py-2.5 border border-slate-700 rounded-xl shadow-sm placeholder-slate-500 bg-slate-800/80 text-white focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition text-sm"
237
+ placeholder="••••••••"
238
+ />
239
+ </div>
240
+ <p v-if="form.errors.password" class="mt-2 text-sm text-rose-400">{{ form.errors.password }}</p>
241
+ </div>
242
+
243
+ <div class="flex items-center justify-between">
244
+ <div class="flex items-center">
245
+ <input
246
+ id="remember"
247
+ v-model="form.remember"
248
+ type="checkbox"
249
+ class="h-4 w-4 text-sky-500 focus:ring-sky-400 border-slate-700 rounded bg-slate-800"
250
+ />
251
+ <label for="remember" class="ml-2 block text-sm text-slate-400">Remember me</label>
252
+ </div>
253
+ </div>
254
+
255
+ <div>
256
+ <button
257
+ type="submit"
258
+ :disabled="form.processing"
259
+ class="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-xl shadow-md text-sm font-semibold text-white bg-sky-500 hover:bg-sky-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500 disabled:opacity-50 transition duration-200"
260
+ >
261
+ <span v-if="form.processing">Signing in...</span>
262
+ <span v-else>Sign In</span>
263
+ </button>
264
+ </div>
265
+ </form>
266
+ </div>
267
+ </div>
268
+ </div>
269
+ </template>
270
+ """
271
+
272
+ VUE_REGISTER_TEMPLATE = """<script setup lang="ts">
273
+ import { useForm, Link } from '@inertiajs/vue3'
274
+ import AstrisLogo from '../../Components/AstrisLogo.vue'
275
+
276
+ const form = useForm({
277
+ name: '',
278
+ email: '',
279
+ password: '',
280
+ password_confirmation: '',
281
+ })
282
+
283
+ const submit = () => {
284
+ form.post('/register', {
285
+ onFinish: () => form.reset('password', 'password_confirmation'),
286
+ })
287
+ }
288
+ </script>
289
+
290
+ <template>
291
+ <div class="min-h-screen bg-slate-950 flex flex-col justify-center py-12 sm:px-6 lg:px-8 text-slate-100 font-sans relative selection:bg-sky-500 selection:text-white">
292
+ <!-- Subtle Background Glow -->
293
+ <div class="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[300px] bg-sky-500/10 blur-[100px] rounded-full pointer-events-none -z-10"></div>
294
+
295
+ <div class="sm:mx-auto sm:w-full sm:max-w-md">
296
+ <div class="flex justify-center">
297
+ <Link href="/" class="p-3 rounded-2xl bg-slate-900/80 border border-slate-800 backdrop-blur shadow-xl hover:border-sky-500/40 transition duration-200">
298
+ <AstrisLogo class="w-10 h-10 text-sky-400" />
299
+ </Link>
300
+ </div>
301
+ <h2 class="mt-4 text-center text-3xl font-extrabold tracking-tight text-white">
302
+ Create a new account
303
+ </h2>
304
+ <p class="mt-2 text-center text-sm text-slate-400">
305
+ Already have an account?
306
+ <Link href="/login" class="font-medium text-sky-400 hover:text-sky-300 underline underline-offset-4 transition">
307
+ Sign in
308
+ </Link>
309
+ </p>
310
+ </div>
311
+
312
+ <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
313
+ <div class="bg-slate-900/80 backdrop-blur border border-slate-800 py-8 px-6 shadow-2xl rounded-2xl sm:px-10">
314
+ <form @submit.prevent="submit" class="space-y-5">
315
+ <div>
316
+ <label for="name" class="block text-sm font-medium text-slate-300">Full Name</label>
317
+ <div class="mt-1">
318
+ <input
319
+ id="name"
320
+ v-model="form.name"
321
+ type="text"
322
+ autocomplete="name"
323
+ required
324
+ class="appearance-none block w-full px-3.5 py-2.5 border border-slate-700 rounded-xl shadow-sm placeholder-slate-500 bg-slate-800/80 text-white focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition text-sm"
325
+ placeholder="Jane Doe"
326
+ />
327
+ </div>
328
+ <p v-if="form.errors.name" class="mt-2 text-sm text-rose-400">{{ form.errors.name }}</p>
329
+ </div>
330
+
331
+ <div>
332
+ <label for="email" class="block text-sm font-medium text-slate-300">Email address</label>
333
+ <div class="mt-1">
334
+ <input
335
+ id="email"
336
+ v-model="form.email"
337
+ type="email"
338
+ autocomplete="email"
339
+ required
340
+ class="appearance-none block w-full px-3.5 py-2.5 border border-slate-700 rounded-xl shadow-sm placeholder-slate-500 bg-slate-800/80 text-white focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition text-sm"
341
+ placeholder="you@example.com"
342
+ />
343
+ </div>
344
+ <p v-if="form.errors.email" class="mt-2 text-sm text-rose-400">{{ form.errors.email }}</p>
345
+ </div>
346
+
347
+ <div>
348
+ <label for="password" class="block text-sm font-medium text-slate-300">Password</label>
349
+ <div class="mt-1">
350
+ <input
351
+ id="password"
352
+ v-model="form.password"
353
+ type="password"
354
+ autocomplete="new-password"
355
+ required
356
+ class="appearance-none block w-full px-3.5 py-2.5 border border-slate-700 rounded-xl shadow-sm placeholder-slate-500 bg-slate-800/80 text-white focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition text-sm"
357
+ placeholder="••••••••"
358
+ />
359
+ </div>
360
+ <p v-if="form.errors.password" class="mt-2 text-sm text-rose-400">{{ form.errors.password }}</p>
361
+ </div>
362
+
363
+ <div>
364
+ <label for="password_confirmation" class="block text-sm font-medium text-slate-300">Confirm Password</label>
365
+ <div class="mt-1">
366
+ <input
367
+ id="password_confirmation"
368
+ v-model="form.password_confirmation"
369
+ type="password"
370
+ autocomplete="new-password"
371
+ required
372
+ class="appearance-none block w-full px-3.5 py-2.5 border border-slate-700 rounded-xl shadow-sm placeholder-slate-500 bg-slate-800/80 text-white focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition text-sm"
373
+ placeholder="••••••••"
374
+ />
375
+ </div>
376
+ <p v-if="form.errors.password_confirmation" class="mt-2 text-sm text-rose-400">{{ form.errors.password_confirmation }}</p>
377
+ </div>
378
+
379
+ <div>
380
+ <button
381
+ type="submit"
382
+ :disabled="form.processing"
383
+ class="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-xl shadow-md text-sm font-semibold text-white bg-sky-500 hover:bg-sky-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500 disabled:opacity-50 transition duration-200"
384
+ >
385
+ <span v-if="form.processing">Creating account...</span>
386
+ <span v-else>Register</span>
387
+ </button>
388
+ </div>
389
+ </form>
390
+ </div>
391
+ </div>
392
+ </div>
393
+ </template>
394
+ """
395
+
396
+ VUE_DASHBOARD_TEMPLATE = """<script setup lang="ts">
397
+ import { usePage, router, Link } from '@inertiajs/vue3'
398
+ import AstrisLogo from '../Components/AstrisLogo.vue'
399
+
400
+ const page = usePage()
401
+
402
+ const logout = () => {
403
+ router.post('/logout')
404
+ }
405
+ </script>
406
+
407
+ <template>
408
+ <div class="min-h-screen bg-slate-950 text-slate-100 font-sans relative selection:bg-sky-500 selection:text-white">
409
+ <!-- Subtle Background Ambient Glow -->
410
+ <div class="absolute top-0 right-1/4 w-[500px] h-[250px] bg-sky-500/10 blur-[120px] rounded-full pointer-events-none -z-10"></div>
411
+
412
+ <nav class="border-b border-slate-800 bg-slate-900/60 backdrop-blur sticky top-0 z-50">
413
+ <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
414
+ <div class="flex justify-between h-16 items-center">
415
+ <Link href="/" class="flex items-center space-x-3 group">
416
+ <AstrisLogo class="w-8 h-8 text-sky-400 group-hover:scale-105 transition duration-200" />
417
+ <span class="font-bold text-lg text-white">Astris App</span>
418
+ </Link>
419
+ <div class="flex items-center space-x-4">
420
+ <div class="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-slate-800/60 border border-slate-700/60 text-xs font-medium text-slate-300">
421
+ <span class="w-2 h-2 rounded-full bg-emerald-400"></span>
422
+ {{ page.props.auth?.user?.name || page.props.auth?.user?.email }}
423
+ </div>
424
+ <button
425
+ @click="logout"
426
+ class="px-3.5 py-1.5 rounded-xl text-xs font-medium text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 transition duration-200"
427
+ >
428
+ Sign Out
429
+ </button>
430
+ </div>
431
+ </div>
432
+ </div>
433
+ </nav>
434
+
435
+ <main class="max-w-7xl mx-auto py-10 px-4 sm:px-6 lg:px-8">
436
+ <div v-if="page.props.flash?.success" class="mb-6 p-4 rounded-2xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 text-sm flex items-center gap-2.5">
437
+ <svg class="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
438
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
439
+ </svg>
440
+ <span>{{ page.props.flash.success }}</span>
441
+ </div>
442
+
443
+ <div class="bg-slate-900/80 border border-slate-800 rounded-3xl p-8 sm:p-10 shadow-2xl backdrop-blur">
444
+ <h1 class="text-3xl font-extrabold text-white">Dashboard</h1>
445
+ <p class="mt-2 text-slate-400">
446
+ Welcome to your authenticated Astris application!
447
+ </p>
448
+
449
+ <div class="mt-8 grid grid-cols-1 md:grid-cols-3 gap-5">
450
+ <div class="p-6 rounded-2xl bg-slate-800/50 border border-slate-700/60 shadow-sm">
451
+ <div class="text-xs font-medium text-slate-400 uppercase tracking-wider">Session Status</div>
452
+ <div class="mt-2 text-xl font-bold text-emerald-400 flex items-center gap-2">
453
+ <span class="w-2.5 h-2.5 rounded-full bg-emerald-400 shadow-[0_0_8px_#34d399]"></span>
454
+ Authenticated
455
+ </div>
456
+ </div>
457
+ <div class="p-6 rounded-2xl bg-slate-800/50 border border-slate-700/60 shadow-sm">
458
+ <div class="text-xs font-medium text-slate-400 uppercase tracking-wider">User Email</div>
459
+ <div class="mt-2 text-base font-semibold text-white truncate">{{ page.props.auth?.user?.email }}</div>
460
+ </div>
461
+ <div class="p-6 rounded-2xl bg-slate-800/50 border border-slate-700/60 shadow-sm">
462
+ <div class="text-xs font-medium text-slate-400 uppercase tracking-wider">Security Engine</div>
463
+ <div class="mt-2 text-base font-semibold text-sky-400 flex items-center gap-2">
464
+ <span>🛡️</span>
465
+ Signed Cookies & CSRF
466
+ </div>
467
+ </div>
468
+ </div>
469
+ </div>
470
+ </main>
471
+ </div>
472
+ </template>
473
+ """
474
+
475
+
476
+ def install_auth_starter(base_path: Path | None = None) -> None:
477
+ """Install a full-stack authentication starter kit (models, services, controllers, and Vue pages)."""
478
+ root = base_path or Path.cwd()
479
+
480
+ # 1. Backend module: app/modules/auth/
481
+ auth_module_dir = root / "app" / "modules" / "auth"
482
+ auth_module_dir.mkdir(parents=True, exist_ok=True)
483
+ (auth_module_dir / "__init__.py").touch()
484
+
485
+ (auth_module_dir / "auth_model.py").write_text(
486
+ AUTH_MODEL_TEMPLATE, encoding="utf-8"
487
+ )
488
+ (auth_module_dir / "auth_service.py").write_text(
489
+ AUTH_SERVICE_TEMPLATE, encoding="utf-8"
490
+ )
491
+ (auth_module_dir / "auth_controller.py").write_text(
492
+ AUTH_CONTROLLER_TEMPLATE, encoding="utf-8"
493
+ )
494
+
495
+ # 2. Components: resources/js/Components/AstrisLogo.vue
496
+ components_dir = root / "resources" / "js" / "Components"
497
+ components_dir.mkdir(parents=True, exist_ok=True)
498
+ logo_file = components_dir / "AstrisLogo.vue"
499
+ if not logo_file.exists():
500
+ logo_file.write_text(ASTRIS_LOGO_VUE_TEMPLATE, encoding="utf-8")
501
+
502
+ # 3. Frontend pages: resources/js/Pages/Auth/
503
+ auth_pages_dir = root / "resources" / "js" / "Pages" / "Auth"
504
+ auth_pages_dir.mkdir(parents=True, exist_ok=True)
505
+
506
+ (auth_pages_dir / "Login.vue").write_text(VUE_LOGIN_TEMPLATE, encoding="utf-8")
507
+ (auth_pages_dir / "Register.vue").write_text(
508
+ VUE_REGISTER_TEMPLATE, encoding="utf-8"
509
+ )
510
+
511
+ pages_dir = root / "resources" / "js" / "Pages"
512
+ (pages_dir / "Dashboard.vue").write_text(VUE_DASHBOARD_TEMPLATE, encoding="utf-8")