committing everything before round 2

This commit is contained in:
2026-08-12 14:14:09 -06:00
parent 27ae08690e
commit d14488d2f2
36 changed files with 562 additions and 55 deletions
+1 -1
View File
@@ -7,7 +7,7 @@ fmt:
vet: fmt vet: fmt
go vet ./... go vet ./...
build: vet build:
cd web && pnpm run build cd web && pnpm run build
go build -v -o solopm-server ./cmd/server go build -v -o solopm-server ./cmd/server
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+3 -1
View File
@@ -9,6 +9,8 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"solopm.com/solopm-server/auth"
"github.com/alexedwards/scs/v2" "github.com/alexedwards/scs/v2"
"github.com/joho/godotenv" "github.com/joho/godotenv"
) )
@@ -22,7 +24,7 @@ func main() {
} }
// handle authentication // handle authentication
sessionManager = scs.New() sessionManager = scs.NewServerMux()
sessionManager.Lifetime = 24 * time.Hour sessionManager.Lifetime = 24 * time.Hour
// handle frontend views // handle frontend views
+21
View File
@@ -0,0 +1,21 @@
package auth
import (
"github.com/alexedwards/scs/v2"
"golang.org/x/crypto/bcrypt"
)
// remember, if anyone attempts to login as the system or zombie user, fail them. hard.
// sessionManager needs to be figured out. do i inherit that from somewhere else?
// does it get associated with any other one like PHP's `session_open()` if i call it here?
func set(writer http.ResponseWriter, request *http.Request) {
sessionManager.Put(request.Context(), "sessionKey", "message to be passed around")
}
func get(writer http.ResponseWriter, request *http.Request) String {
return sessionManager.GetString(request.Context(), "sessionKey")
}
+3
View File
@@ -0,0 +1,3 @@
package auth
//import ()
+4
View File
@@ -0,0 +1,4 @@
package auth
//import ()
+16
View File
@@ -0,0 +1,16 @@
package auth
import (
"net/http"
)
func requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if !sessionManager.Exists(request.Context(), "userID") {
http.Redirect(writer, request, "/login", http.StatusSeeOther)
return
}
next.ServeHTTP(writer, request)
})
}
Binary file not shown.
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/bash
for up_file in *.up.sql; do
down_file="${up_file%.up.sql}.down.sql"
quoted_text=$(sed -n '3s/[^"]*\("[^"]*"\).*/\1/p' "$up_file")
first_line=$(head -n 1 "$up_file")
if [[ "$first_line" != *".up.sql" ]]; then
sed -i "1s/$/\.up\.sql/" "$up_file"
fi
head -n 3 "$up_file" > "$down_file"
echo "DROP TABLE IF EXISTS ${quoted_text};" >> "$down_file"
tail -n 1 "$up_file" >> "$down_file"
done
sed -i 's/\bCREATE\b/DROP/g' *.down.sql
for file in *.sql; do
[ -f "$file" ] || continue
line3=$(sed -n '3p' "$file")
len=${#line3}
if [ "$len" -lt 64 ]; then
needed=$((64 - len))
dashes=$(printf '%*s' "$needed" | tr ' ' '-')
sed -i "3s/$/$dashes/" "$file"
fi
done
for file in *.down.sql; do
[ -f "$file" ] || continue
first_line=$(head -n 1 "$file")
if [[ "$first_line" == *".up.sql" ]]; then
sed -i '1s/\.up\.sql$/\.down\.sql/' "$file"
fi
done
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
+69 -11
View File
@@ -1,17 +1,75 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en-US">
<head> <head>
<meta charset="UTF-8" /> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>SoloPM</title>
<link href="/assets/css/open-sans.css" rel="stylesheet" /> <!-- Set the base URL for all relative URLs within the document -->
<link href="/assets/css/fontawesome/all.min.css" rel="stylesheet" /> <!-- <base href="https://example.com/"> -->
<link href="/assets/css/nucleo-icons.css" rel="stylesheet" />
<link href="/assets/css/nucleo-svg.css" rel="stylesheet" /> <meta name="robots" content="index,follow">
<meta name="googlebot" content="index,follow">
<meta name="format-detection" content="telephone=no">
<meta http-equiv="x-dns-prefetch-control" content="off">
<meta name="google" content="notranslate">
<meta name="google" content="nositelinkssearchbox">
<meta name="rating" content="General">
<!-- <meta name="url" content="https://example.com/"> -->
<!-- <meta name="subject" content="your website's subject"> -->
<!-- <meta name="description" content="A description of the page"> -->
<title>Page Title</title>
<!-- Privacy -->
<!-- <meta name="twitter:dnt" content="on"> -->
<!-- <meta name="pinterest" content="nopin" description="No pinning allowed."> -->
<!-- analytics -->
<!-- -->
<!-- Android web manifest file -->
<!-- <link href="https://example.com/.webmanifest" rel="manifest"> -->
<!-- Files listing who was involved in this site and copyrights -->
<!-- <link href="https://example.com/humans.txt" rel="author"> -->
<!-- <link href="https://example.com/copyright.html" rel="copyright"> -->
<!-- Feeds -->
<!-- <link href="https://example.com/rss.xml" type="application/rss+xml" title="RSS" rel="alternate"> -->
<!-- <link href="https://example.com/feed.atom" type="application/atom+xml" title="Atom 0.3" rel="alternate"> -->
<!-- Favicon -->
<!-- <link href="https://example.com/favicon.ico" rel="icon" sizes="16x16" type="image/icon"> -->
<!-- <link href="https://example.com/favicon.svg" rel="icon" type="image/svg+xml"> -->
<!-- <link href="https://example.com/favicon.png" rel="icon" sizes="192x192"> -->
<!-- Font preloads (should be done for each font file) -->
<link href="/assets/fonts/open-sans/open-sans-600.woff2" rel="preload" as="font" type="font/woff2" crossorigin="anonymous">
<link href="/assets/fonts/nucleo.woff2" rel="preload" as="font" type="font/woff2" crossorigin="anonymous">
<link href="/assets/fonts/nucleo-icons.woff2" rel="preload" as="font" type="font/woff2" crossorigin="anonymous">
<!-- CSS -->
<link href="/assets/css/open-sans.css" rel="stylesheet" media="screen" />
<link href="/assets/css/fontawesome/all.min.css" rel="stylesheet" media="screen" />
<link href="/assets/css/nucleo-icons.css" rel="stylesheet" media="screen" />
<link href="/assets/css/nucleo-svg.css" rel="stylesheet" media="screen" />
<!-- JS that must be executed before the document is loaded -->
<script>
if (localStorage.theme === "dark" || (!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
document.documentElement.classList.add("dark"); localStorage.theme = "dark";
} else {
document.documentElement.classList.remove("dark"); localStorage.theme = "light";
}
</script>
<script type="module" src="/src/main.ts" defer></script>
</head> </head>
<body class="m-0 font-sans antialiased font-normal text-base leading-default bg-gray-50 text-slate-500"> <body class="m-0 font-sans font-normal antialiased text-base leading-default bg-gray-50 text-slate-500">
<div id="app"></div> <!-- <body class="font-sans antialiased"> -->
<script type="module" src="/src/main.ts"></script> <div id="app" class="min-h-screen"></div>
</body> </body>
</html> </html>
+21
View File
@@ -0,0 +1,21 @@
import client from './client'
import type { User } from '@/stores/auth'
export interface UpdateProfilePayload {
name: string
email: string
avatar_url?: string
}
export interface ChangePasswordPayload {
current_password: string
new_password: string
}
export const userApi = {
updateProfile: (payload: UpdateProfilePayload) =>
client.put<User>('/me', payload),
changePassword: (payload: ChangePasswordPayload) =>
client.put('/me/password', payload),
}
+10 -4
View File
@@ -42,13 +42,19 @@
</div> </div>
<ul class="flex flex-row justify-end pl-0 mb-0 list-none md-max:w-full"> <ul class="flex flex-row justify-end pl-0 mb-0 list-none md-max:w-full">
<li class="flex items-center"> <li class="flex items-center gap-3">
<RouterLink
to="/profile"
class="text-sm font-semibold text-slate-500 hover:text-slate-700 transition-colors ease-nav-brand"
>
{{ auth.user?.name ?? 'Account' }}
</RouterLink>
<button <button
class="block px-0 py-2 font-semibold transition-all ease-nav-brand text-sm text-slate-500 hover:text-slate-700 bg-transparent border-0 cursor-pointer" class="block py-2 font-semibold transition-all ease-nav-brand text-sm text-slate-500 hover:text-slate-700 bg-transparent border-0 cursor-pointer"
title="Sign out"
@click="handleLogout" @click="handleLogout"
> >
<i class="fas fa-sign-out-alt text-lg mr-1"></i> <i class="fas fa-sign-out-alt text-lg"></i>
<span class="sm:inline">{{ auth.user?.name ?? 'Account' }}</span>
</button> </button>
</li> </li>
</ul> </ul>
+2 -14
View File
@@ -31,21 +31,9 @@
<a <a
:href="href" :href="href"
@click="navigate" @click="navigate"
:class="[ :class="['nav-item', isActive ? 'nav-item-active' : 'nav-item-inactive']"
'py-2.7 text-sm ease-nav-brand my-0 mx-4 flex items-center whitespace-nowrap rounded-lg px-4 font-semibold transition-colors',
isActive
? 'shadow-soft-xl bg-white text-slate-700'
: 'text-slate-500 hover:text-slate-700',
]"
>
<div
:class="[
'mr-2 flex h-8 w-8 items-center justify-center rounded-lg bg-center stroke-0 text-center xl:p-2.5',
isActive
? 'bg-gradient-to-tl from-purple-700 to-pink-500 shadow-soft-2xl'
: 'bg-white shadow-soft-sm',
]"
> >
<div :class="['nav-icon', isActive ? 'nav-icon-active' : 'nav-icon-inactive']">
<i <i
:class="[ :class="[
'fas fa-tachometer-alt text-sm', 'fas fa-tachometer-alt text-sm',
+43
View File
@@ -0,0 +1,43 @@
<script setup>
import { useAttrs, useSlots, reactive, ref, computed, watch, onBeforeMount, onMounted, onBeforeUpdate, onBeforeUnmount, onUnmounted, provide, inject } from 'vue'
// defines
defineEmits([])
// variables
const props = defineProps({})
// computed properties
// watchers
// lifecycle hooks
onBeforeMount(() => {
//
})
onMounted(() => {
//
})
onBeforeUpdate(() => {
//
})
onBeforeUnmount(() => {
//
})
onUnmounted(() => {
//
})
// methods
</script>
<template>
<div class="card">
</div>
</template>
+8 -8
View File
@@ -2,7 +2,7 @@
<div class="min-h-screen flex items-center justify-center bg-gray-50"> <div class="min-h-screen flex items-center justify-center bg-gray-50">
<div class="w-full max-w-md px-4"> <div class="w-full max-w-md px-4">
<div class="relative flex flex-col min-w-0 break-words bg-white shadow-soft-xl rounded-2xl bg-clip-border"> <div class="card">
<div class="p-6 pb-0 text-center"> <div class="p-6 pb-0 text-center">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-gradient-to-tl from-purple-700 to-pink-500 shadow-soft-2xl mb-4"> <div class="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-gradient-to-tl from-purple-700 to-pink-500 shadow-soft-2xl mb-4">
@@ -15,24 +15,24 @@
<div class="p-6"> <div class="p-6">
<form @submit.prevent="handleLogin"> <form @submit.prevent="handleLogin">
<div class="mb-4"> <div class="mb-4">
<label class="mb-2 text-xs font-semibold text-slate-700 uppercase">Email</label> <label class="form-label">Email</label>
<input <input
v-model="form.email" v-model="form.email"
type="email" type="email"
required required
placeholder="you@example.com" placeholder="you@example.com"
class="text-sm focus:shadow-soft-primary-outline ease-soft block w-full rounded-lg border border-solid border-gray-300 bg-white bg-clip-padding py-2 px-3 text-gray-700 transition-all placeholder:text-gray-500 focus:border-fuchsia-300 focus:outline-none focus:transition-shadow" class="form-input"
/> />
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="mb-2 text-xs font-semibold text-slate-700 uppercase">Password</label> <label class="form-label">Password</label>
<input <input
v-model="form.password" v-model="form.password"
type="password" type="password"
required required
placeholder="••••••••" placeholder="••••••••"
class="text-sm focus:shadow-soft-primary-outline ease-soft block w-full rounded-lg border border-solid border-gray-300 bg-white bg-clip-padding py-2 px-3 text-gray-700 transition-all placeholder:text-gray-500 focus:border-fuchsia-300 focus:outline-none focus:transition-shadow" class="form-input"
/> />
</div> </div>
@@ -41,7 +41,7 @@
<button <button
type="submit" type="submit"
:disabled="loading" :disabled="loading"
class="inline-block w-full px-6 py-3 font-bold text-center text-white uppercase align-middle transition-all ease-in border-0 rounded-lg select-none shadow-soft-md bg-150 bg-x-25 leading-pro text-xs bg-gradient-to-tl from-purple-700 to-pink-500 hover:shadow-soft-2xl hover:scale-102 disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:scale-100" class="inline-block w-full px-6 py-3 btn-primary"
> >
{{ loading ? 'Signing in…' : 'Sign In' }} {{ loading ? 'Signing in…' : 'Sign In' }}
</button> </button>
@@ -56,7 +56,7 @@
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<a <a
href="/api/auth/github" href="/api/auth/github"
class="inline-flex items-center justify-center w-full px-6 py-2.5 font-bold text-center text-slate-700 uppercase transition-all ease-in bg-white border border-solid border-gray-300 rounded-lg shadow-soft-sm text-xs hover:shadow-soft-md hover:scale-102" class="inline-flex items-center justify-center w-full px-6 py-2.5 btn-white"
> >
<i class="fab fa-github mr-2 text-base"></i> <i class="fab fa-github mr-2 text-base"></i>
Sign in with GitHub Sign in with GitHub
@@ -64,7 +64,7 @@
<a <a
href="/api/auth/gitea" href="/api/auth/gitea"
class="inline-flex items-center justify-center w-full px-6 py-2.5 font-bold text-center text-slate-700 uppercase transition-all ease-in bg-white border border-solid border-gray-300 rounded-lg shadow-soft-sm text-xs hover:shadow-soft-md hover:scale-102" class="inline-flex items-center justify-center w-full px-6 py-2.5 btn-white"
> >
<i class="fab fa-git-alt mr-2 text-base"></i> <i class="fab fa-git-alt mr-2 text-base"></i>
Sign in with Gitea Sign in with Gitea
+13 -13
View File
@@ -4,7 +4,7 @@
<div class="flex flex-wrap -mx-3"> <div class="flex flex-wrap -mx-3">
<div class="w-full max-w-full px-3 mb-6 sm:w-1/2 sm:flex-none xl:mb-0 xl:w-1/4"> <div class="w-full max-w-full px-3 mb-6 sm:w-1/2 sm:flex-none xl:mb-0 xl:w-1/4">
<div class="relative flex flex-col min-w-0 break-words bg-white shadow-soft-xl rounded-2xl bg-clip-border"> <div class="card">
<div class="flex-auto p-4"> <div class="flex-auto p-4">
<div class="flex flex-row -mx-3"> <div class="flex flex-row -mx-3">
<div class="flex-none w-2/3 max-w-full px-3"> <div class="flex-none w-2/3 max-w-full px-3">
@@ -12,7 +12,7 @@
<h5 class="mb-0 font-bold"></h5> <h5 class="mb-0 font-bold"></h5>
</div> </div>
<div class="px-3 text-right basis-1/3"> <div class="px-3 text-right basis-1/3">
<div class="inline-flex w-12 h-12 text-center rounded-lg items-center justify-center bg-gradient-to-tl from-blue-600 to-cyan-400 shadow-soft-2xl"> <div class="inline-flex w-12 h-12 text-center rounded-lg items-center justify-center bg-gradient-to-tl from-slate-600 to-slate-300 shadow-soft-2xl">
<i class="fas fa-tasks text-white text-lg relative"></i> <i class="fas fa-tasks text-white text-lg relative"></i>
</div> </div>
</div> </div>
@@ -22,7 +22,7 @@
</div> </div>
<div class="w-full max-w-full px-3 mb-6 sm:w-1/2 sm:flex-none xl:mb-0 xl:w-1/4"> <div class="w-full max-w-full px-3 mb-6 sm:w-1/2 sm:flex-none xl:mb-0 xl:w-1/4">
<div class="relative flex flex-col min-w-0 break-words bg-white shadow-soft-xl rounded-2xl bg-clip-border"> <div class="card">
<div class="flex-auto p-4"> <div class="flex-auto p-4">
<div class="flex flex-row -mx-3"> <div class="flex flex-row -mx-3">
<div class="flex-none w-2/3 max-w-full px-3"> <div class="flex-none w-2/3 max-w-full px-3">
@@ -30,7 +30,7 @@
<h5 class="mb-0 font-bold"></h5> <h5 class="mb-0 font-bold"></h5>
</div> </div>
<div class="px-3 text-right basis-1/3"> <div class="px-3 text-right basis-1/3">
<div class="inline-flex w-12 h-12 text-center rounded-lg items-center justify-center bg-gradient-to-tl from-green-600 to-lime-400 shadow-soft-2xl"> <div class="inline-flex w-12 h-12 text-center rounded-lg items-center justify-center bg-gradient-to-tl from-blue-600 to-cyan-400 shadow-soft-2xl">
<i class="fas fa-spinner text-white text-lg relative"></i> <i class="fas fa-spinner text-white text-lg relative"></i>
</div> </div>
</div> </div>
@@ -40,7 +40,7 @@
</div> </div>
<div class="w-full max-w-full px-3 mb-6 sm:w-1/2 sm:flex-none xl:mb-0 xl:w-1/4"> <div class="w-full max-w-full px-3 mb-6 sm:w-1/2 sm:flex-none xl:mb-0 xl:w-1/4">
<div class="relative flex flex-col min-w-0 break-words bg-white shadow-soft-xl rounded-2xl bg-clip-border"> <div class="card">
<div class="flex-auto p-4"> <div class="flex-auto p-4">
<div class="flex flex-row -mx-3"> <div class="flex flex-row -mx-3">
<div class="flex-none w-2/3 max-w-full px-3"> <div class="flex-none w-2/3 max-w-full px-3">
@@ -48,7 +48,7 @@
<h5 class="mb-0 font-bold"></h5> <h5 class="mb-0 font-bold"></h5>
</div> </div>
<div class="px-3 text-right basis-1/3"> <div class="px-3 text-right basis-1/3">
<div class="inline-flex w-12 h-12 text-center rounded-lg items-center justify-center bg-gradient-to-tl from-slate-600 to-slate-300 shadow-soft-2xl"> <div class="inline-flex w-12 h-12 text-center rounded-lg items-center justify-center bg-gradient-to-tl from-green-600 to-lime-400 shadow-soft-2xl">
<i class="fas fa-check-circle text-white text-lg relative"></i> <i class="fas fa-check-circle text-white text-lg relative"></i>
</div> </div>
</div> </div>
@@ -58,7 +58,7 @@
</div> </div>
<div class="w-full max-w-full px-3 mb-6 sm:w-1/2 sm:flex-none xl:mb-0 xl:w-1/4"> <div class="w-full max-w-full px-3 mb-6 sm:w-1/2 sm:flex-none xl:mb-0 xl:w-1/4">
<div class="relative flex flex-col min-w-0 break-words bg-white shadow-soft-xl rounded-2xl bg-clip-border"> <div class="card">
<div class="flex-auto p-4"> <div class="flex-auto p-4">
<div class="flex flex-row -mx-3"> <div class="flex flex-row -mx-3">
<div class="flex-none w-2/3 max-w-full px-3"> <div class="flex-none w-2/3 max-w-full px-3">
@@ -80,24 +80,24 @@
<div class="flex flex-wrap mt-6 -mx-3"> <div class="flex flex-wrap mt-6 -mx-3">
<div class="w-full max-w-full px-3 mt-4 lg:w-7/12 lg:flex-none lg:mt-0"> <div class="w-full max-w-full px-3 mt-4 lg:w-7/12 lg:flex-none lg:mt-0">
<div class="relative flex flex-col min-w-0 break-words bg-white border-0 shadow-soft-xl rounded-2xl bg-clip-border"> <div class="card border-0">
<div class="border-black/12.5 rounded-t-2xl border-b-0 border-solid p-6 pb-0"> <div class="card-header">
<h6 class="capitalize">Activity</h6> <h6 class="capitalize">Activity</h6>
<p class="text-sm leading-normal">Recent updates</p> <p class="text-sm leading-normal">Recent updates</p>
</div> </div>
<div class="flex-auto p-6 pt-4"> <div class="card-body">
<p class="text-sm text-slate-500 text-center py-8">No activity yet.</p> <p class="text-sm text-slate-500 text-center py-8">No activity yet.</p>
</div> </div>
</div> </div>
</div> </div>
<div class="w-full max-w-full px-3 mt-0 lg:w-5/12 lg:flex-none"> <div class="w-full max-w-full px-3 mt-0 lg:w-5/12 lg:flex-none">
<div class="relative flex flex-col min-w-0 break-words bg-white border-0 shadow-soft-xl rounded-2xl bg-clip-border"> <div class="card border-0">
<div class="border-black/12.5 rounded-t-2xl border-b-0 border-solid p-6 pb-0"> <div class="card-header">
<h6 class="capitalize">Recent Projects</h6> <h6 class="capitalize">Recent Projects</h6>
<p class="text-sm leading-normal">Your active projects</p> <p class="text-sm leading-normal">Your active projects</p>
</div> </div>
<div class="flex-auto p-6 pt-4"> <div class="card-body">
<p class="text-sm text-slate-500 text-center py-8">No projects yet.</p> <p class="text-sm text-slate-500 text-center py-8">No projects yet.</p>
</div> </div>
</div> </div>
+238
View File
@@ -0,0 +1,238 @@
<template>
<AppLayout>
<!-- Header card -->
<div class="flex flex-wrap -mx-3 mb-6">
<div class="w-full px-3">
<div class="card overflow-hidden">
<div class="h-28 bg-gradient-to-tl from-purple-700 to-pink-500"></div>
<div class="flex-auto px-6 pb-6">
<div class="flex items-end gap-5 -mt-10 mb-3">
<div class="w-20 h-20 flex-shrink-0 rounded-xl border-4 border-white shadow-soft-xl bg-gradient-to-tl from-purple-700 to-pink-500 flex items-center justify-center overflow-hidden">
<img
v-if="auth.user?.avatar_url"
:src="auth.user.avatar_url"
class="w-full h-full object-cover"
:alt="auth.user.name"
/>
<span v-else class="text-2xl font-bold text-white select-none">{{ initials }}</span>
</div>
<div class="pb-1">
<h5 class="mb-0 font-bold">{{ auth.user?.name }}</h5>
<p class="text-sm text-slate-500 mb-0">
@{{ auth.user?.username }}
<span class="mx-1 opacity-40">·</span>
{{ auth.user?.email }}
<span v-if="auth.user?.is_admin" class="ml-2 inline-block px-2 py-0.5 text-xs font-semibold uppercase rounded-md bg-gradient-to-tl from-purple-700 to-pink-500 text-white">
Admin
</span>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Forms row -->
<div class="flex flex-wrap -mx-3">
<!-- Edit profile -->
<div class="w-full max-w-full px-3 lg:w-7/12">
<div class="card border-0">
<div class="card-header">
<h6 class="capitalize">Profile Information</h6>
<p class="text-sm leading-normal">Update your name, email, and avatar.</p>
</div>
<div class="card-body">
<form @submit.prevent="saveProfile">
<div class="mb-4">
<label class="form-label">Display Name</label>
<input
v-model="profileForm.name"
type="text"
required
placeholder="Your name"
class="form-input"
/>
</div>
<div class="mb-4">
<label class="form-label">Email</label>
<input
v-model="profileForm.email"
type="email"
required
placeholder="you@example.com"
class="form-input"
/>
</div>
<div class="mb-6">
<label class="form-label">Avatar URL</label>
<input
v-model="profileForm.avatar_url"
type="url"
placeholder="https://..."
class="form-input"
/>
</div>
<p v-if="profileError" class="text-xs text-red-600 mb-3">{{ profileError }}</p>
<p v-if="profileSuccess" class="text-xs text-green-600 mb-3">Profile updated.</p>
<button
type="submit"
:disabled="profileLoading"
class="inline-block px-6 py-2.5 btn-primary"
>
{{ profileLoading ? 'Saving…' : 'Save Changes' }}
</button>
</form>
</div>
</div>
</div>
<!-- Change password (local accounts only) -->
<div class="w-full max-w-full px-3 mt-6 lg:w-5/12 lg:mt-0">
<div class="card border-0">
<div class="card-header">
<h6 class="capitalize">Change Password</h6>
<p class="text-sm leading-normal">
<template v-if="auth.user?.provider === 'local'">
Choose a strong password.
</template>
<template v-else>
Your account uses <span class="font-semibold capitalize">{{ auth.user?.provider }}</span> sign-in.
</template>
</p>
</div>
<div class="card-body">
<form v-if="auth.user?.provider === 'local'" @submit.prevent="savePassword">
<div class="mb-4">
<label class="form-label">Current Password</label>
<input
v-model="passwordForm.current_password"
type="password"
required
placeholder="••••••••"
class="form-input"
/>
</div>
<div class="mb-4">
<label class="form-label">New Password</label>
<input
v-model="passwordForm.new_password"
type="password"
required
placeholder="••••••••"
class="form-input"
/>
</div>
<div class="mb-6">
<label class="form-label">Confirm New Password</label>
<input
v-model="confirmPassword"
type="password"
required
placeholder="••••••••"
class="form-input"
/>
</div>
<p v-if="passwordError" class="text-xs text-red-600 mb-3">{{ passwordError }}</p>
<p v-if="passwordSuccess" class="text-xs text-green-600 mb-3">Password updated.</p>
<button
type="submit"
:disabled="passwordLoading"
class="inline-block px-6 py-2.5 btn-primary"
>
{{ passwordLoading ? 'Updating…' : 'Update Password' }}
</button>
</form>
<div v-else class="py-6 text-center">
<div class="inline-flex w-12 h-12 rounded-lg items-center justify-center bg-gradient-to-tl from-slate-600 to-slate-300 shadow-soft-2xl mb-3">
<i class="fas fa-shield-alt text-white text-lg"></i>
</div>
<p class="text-sm text-slate-500">Password is managed by your <span class="capitalize font-semibold">{{ auth.user?.provider }}</span> account.</p>
</div>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import AppLayout from '@/components/layout/AppLayout.vue'
import { useAuthStore } from '@/stores/auth'
import { userApi } from '@/api/user'
const auth = useAuthStore()
const initials = computed(() => {
const name = auth.user?.name ?? ''
return name.split(' ').map(w => w[0]).slice(0, 2).join('').toUpperCase()
})
const profileForm = ref({
name: auth.user?.name ?? '',
email: auth.user?.email ?? '',
avatar_url: auth.user?.avatar_url ?? '',
})
const profileLoading = ref(false)
const profileError = ref('')
const profileSuccess = ref(false)
const passwordForm = ref({ current_password: '', new_password: '' })
const confirmPassword = ref('')
const passwordLoading = ref(false)
const passwordError = ref('')
const passwordSuccess = ref(false)
onMounted(() => {
profileForm.value = {
name: auth.user?.name ?? '',
email: auth.user?.email ?? '',
avatar_url: auth.user?.avatar_url ?? '',
}
})
async function saveProfile() {
profileError.value = ''
profileSuccess.value = false
profileLoading.value = true
try {
const { data } = await userApi.updateProfile(profileForm.value)
auth.setUser(data)
profileSuccess.value = true
} catch {
profileError.value = 'Failed to update profile. Please try again.'
} finally {
profileLoading.value = false
}
}
async function savePassword() {
passwordError.value = ''
passwordSuccess.value = false
if (passwordForm.value.new_password !== confirmPassword.value) {
passwordError.value = 'Passwords do not match.'
return
}
passwordLoading.value = true
try {
await userApi.changePassword(passwordForm.value)
passwordSuccess.value = true
passwordForm.value = { current_password: '', new_password: '' }
confirmPassword.value = ''
} catch {
passwordError.value = 'Failed to update password. Check your current password and try again.'
} finally {
passwordLoading.value = false
}
}
</script>
+6
View File
@@ -26,6 +26,12 @@ const router = createRouter({
component: () => import('@/pages/dashboard/DashboardPage.vue'), component: () => import('@/pages/dashboard/DashboardPage.vue'),
meta: { title: 'Dashboard' }, meta: { title: 'Dashboard' },
}, },
{
path: '/profile',
name: 'profile',
component: () => import('@/pages/profile/ProfilePage.vue'),
meta: { title: 'Profile' },
},
], ],
}) })
+3
View File
@@ -3,9 +3,12 @@ import { ref, computed } from 'vue'
export interface User { export interface User {
id: number id: number
username: string
name: string name: string
email: string email: string
avatar_url?: string avatar_url?: string
is_admin: boolean
provider: string
} }
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
+56
View File
@@ -1,3 +1,59 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer components {
/* ── Card ─────────────────────────────────────────────────────────── */
.card {
@apply relative flex flex-col min-w-0 break-words bg-white shadow-soft-xl rounded-2xl bg-clip-border;
}
.card-header {
/* border-black/12.5 */
@apply rounded-t-2xl border-b-0 border-solid p-6 pb-0;
}
.card-body {
@apply flex-auto p-6 pt-4;
}
/* ── Buttons ──────────────────────────────────────────────────────── */
/* Use display (inline-block / inline-flex) and sizing (px-* py-* w-*) as utilities alongside these. */
.btn-primary {
@apply font-bold text-center text-white uppercase align-middle transition-all ease-in border-0 rounded-lg select-none shadow-soft-md bg-150 bg-x-25 leading-pro text-xs bg-gradient-to-tl from-purple-700 to-pink-500 hover:shadow-soft-2xl hover:scale-102 disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:scale-100;
}
.btn-white {
@apply font-bold text-center text-slate-700 uppercase align-middle transition-all ease-in bg-white border border-solid border-gray-300 rounded-lg shadow-soft-sm leading-pro text-xs hover:shadow-soft-md hover:scale-102;
}
.btn-outlined {
@apply font-bold text-center uppercase align-middle transition-all bg-transparent border border-solid rounded-lg shadow-none cursor-pointer leading-pro text-xs border-fuchsia-500 ease-soft-in text-fuchsia-500 hover:border-fuchsia-500 active:bg-fuchsia-500 hover:bg-transparent hover:shadow-none;
}
/* ── Forms ────────────────────────────────────────────────────────── */
.form-label {
@apply mb-2 text-xs font-semibold text-slate-700 uppercase block;
}
.form-input {
@apply text-sm focus:shadow-soft-primary-outline ease-soft block w-full rounded-lg border border-solid border-gray-300 bg-white bg-clip-padding py-2 px-3 text-gray-700 transition-all placeholder:text-gray-500 focus:border-fuchsia-300 focus:outline-none focus:transition-shadow;
}
/* ── Sidebar nav ──────────────────────────────────────────────────── */
/* .nav-item-active and .nav-item-inactive are modifiers applied alongside .nav-item */
.nav-item {
@apply py-2.7 text-sm ease-nav-brand my-0 mx-4 flex items-center whitespace-nowrap rounded-lg px-4 font-semibold transition-colors;
}
.nav-item-active {
@apply shadow-soft-xl bg-white text-slate-700;
}
.nav-item-inactive {
@apply text-slate-500 hover:text-slate-700;
}
/* .nav-icon-active and .nav-icon-inactive are modifiers applied alongside .nav-icon */
.nav-icon {
@apply mr-2 flex h-8 w-8 items-center justify-center rounded-lg bg-center stroke-0 text-center xl:p-2.5;
}
.nav-icon-active {
@apply bg-gradient-to-tl from-purple-700 to-pink-500 shadow-soft-2xl;
}
.nav-icon-inactive {
@apply bg-white shadow-soft-sm;
}
}