committing everything before round 2
This commit is contained in:
@@ -7,7 +7,7 @@ fmt:
|
||||
vet: fmt
|
||||
go vet ./...
|
||||
|
||||
build: vet
|
||||
build:
|
||||
cd web && pnpm run build
|
||||
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
@@ -9,6 +9,8 @@ import (
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"solopm.com/solopm-server/auth"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
@@ -22,7 +24,7 @@ func main() {
|
||||
}
|
||||
|
||||
// handle authentication
|
||||
sessionManager = scs.New()
|
||||
sessionManager = scs.NewServerMux()
|
||||
sessionManager.Lifetime = 24 * time.Hour
|
||||
|
||||
// handle frontend views
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package auth
|
||||
|
||||
//import ()
|
||||
@@ -0,0 +1,4 @@
|
||||
package auth
|
||||
|
||||
//import ()
|
||||
|
||||
@@ -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.
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
+69
-11
@@ -1,17 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SoloPM</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<link href="/assets/css/open-sans.css" rel="stylesheet" />
|
||||
<link href="/assets/css/fontawesome/all.min.css" rel="stylesheet" />
|
||||
<link href="/assets/css/nucleo-icons.css" rel="stylesheet" />
|
||||
<link href="/assets/css/nucleo-svg.css" rel="stylesheet" />
|
||||
<!-- Set the base URL for all relative URLs within the document -->
|
||||
<!-- <base href="https://example.com/"> -->
|
||||
|
||||
<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>
|
||||
<body class="m-0 font-sans antialiased font-normal text-base leading-default bg-gray-50 text-slate-500">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<body class="m-0 font-sans font-normal antialiased text-base leading-default bg-gray-50 text-slate-500">
|
||||
<!-- <body class="font-sans antialiased"> -->
|
||||
<div id="app" class="min-h-screen"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -42,13 +42,19 @@
|
||||
</div>
|
||||
|
||||
<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
|
||||
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"
|
||||
>
|
||||
<i class="fas fa-sign-out-alt text-lg mr-1"></i>
|
||||
<span class="sm:inline">{{ auth.user?.name ?? 'Account' }}</span>
|
||||
<i class="fas fa-sign-out-alt text-lg"></i>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -31,21 +31,9 @@
|
||||
<a
|
||||
:href="href"
|
||||
@click="navigate"
|
||||
:class="[
|
||||
'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',
|
||||
]"
|
||||
:class="['nav-item', isActive ? 'nav-item-active' : 'nav-item-inactive']"
|
||||
>
|
||||
<div :class="['nav-icon', isActive ? 'nav-icon-active' : 'nav-icon-inactive']">
|
||||
<i
|
||||
:class="[
|
||||
'fas fa-tachometer-alt text-sm',
|
||||
|
||||
@@ -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>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<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="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">
|
||||
<form @submit.prevent="handleLogin">
|
||||
<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
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
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 class="mb-4">
|
||||
<label class="mb-2 text-xs font-semibold text-slate-700 uppercase">Password</label>
|
||||
<label class="form-label">Password</label>
|
||||
<input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
required
|
||||
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>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<button
|
||||
type="submit"
|
||||
: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' }}
|
||||
</button>
|
||||
@@ -56,7 +56,7 @@
|
||||
<div class="flex flex-col gap-3">
|
||||
<a
|
||||
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>
|
||||
Sign in with GitHub
|
||||
@@ -64,7 +64,7 @@
|
||||
|
||||
<a
|
||||
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>
|
||||
Sign in with Gitea
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<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="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 flex-row -mx-3">
|
||||
<div class="flex-none w-2/3 max-w-full px-3">
|
||||
@@ -12,7 +12,7 @@
|
||||
<h5 class="mb-0 font-bold">—</h5>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -22,7 +22,7 @@
|
||||
</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="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 flex-row -mx-3">
|
||||
<div class="flex-none w-2/3 max-w-full px-3">
|
||||
@@ -30,7 +30,7 @@
|
||||
<h5 class="mb-0 font-bold">—</h5>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,7 +40,7 @@
|
||||
</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="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 flex-row -mx-3">
|
||||
<div class="flex-none w-2/3 max-w-full px-3">
|
||||
@@ -48,7 +48,7 @@
|
||||
<h5 class="mb-0 font-bold">—</h5>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,7 +58,7 @@
|
||||
</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="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 flex-row -mx-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="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="border-black/12.5 rounded-t-2xl border-b-0 border-solid p-6 pb-0">
|
||||
<div class="card border-0">
|
||||
<div class="card-header">
|
||||
<h6 class="capitalize">Activity</h6>
|
||||
<p class="text-sm leading-normal">Recent updates</p>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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="border-black/12.5 rounded-t-2xl border-b-0 border-solid p-6 pb-0">
|
||||
<div class="card border-0">
|
||||
<div class="card-header">
|
||||
<h6 class="capitalize">Recent Projects</h6>
|
||||
<p class="text-sm leading-normal">Your active projects</p>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -26,6 +26,12 @@ const router = createRouter({
|
||||
component: () => import('@/pages/dashboard/DashboardPage.vue'),
|
||||
meta: { title: 'Dashboard' },
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: () => import('@/pages/profile/ProfilePage.vue'),
|
||||
meta: { title: 'Profile' },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -3,9 +3,12 @@ import { ref, computed } from 'vue'
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
name: string
|
||||
email: string
|
||||
avatar_url?: string
|
||||
is_admin: boolean
|
||||
provider: string
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
@@ -1,3 +1,59 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user