first working login and editing of file. bugg is för rendering with tiptap
This commit is contained in:
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add:*)",
|
||||
"Bash(git -c:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
README.pdf
Normal file
BIN
README.pdf
Normal file
Binary file not shown.
BIN
backend/chunk.txt
Normal file
BIN
backend/chunk.txt
Normal file
Binary file not shown.
148
backend/internal/db/db.go
Normal file
148
backend/internal/db/db.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var ErrUserNotFound = errors.New("user not found")
|
||||
var ErrUserExists = errors.New("username already exists")
|
||||
|
||||
// User represents a local account stored in SQLite.
|
||||
type User struct {
|
||||
ID int64
|
||||
Username string
|
||||
PassHash string
|
||||
Role string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// DB wraps the SQLite connection.
|
||||
type DB struct {
|
||||
sql *sql.DB
|
||||
}
|
||||
|
||||
// New opens (or creates) the SQLite database at path and runs migrations.
|
||||
func New(path string) (*DB, error) {
|
||||
sqldb, err := sql.Open("sqlite", path+"?_journal=WAL&_timeout=5000")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := &DB{sql: sqldb}
|
||||
if err := d.init(); err != nil {
|
||||
sqldb.Close()
|
||||
return nil, err
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *DB) Close() error {
|
||||
return d.sql.Close()
|
||||
}
|
||||
|
||||
func (d *DB) init() error {
|
||||
_, err := d.sql.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
pass_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateUser inserts a new user. Returns ErrUserExists if the username is taken.
|
||||
func (d *DB) CreateUser(username, passHash, role string) error {
|
||||
_, err := d.sql.Exec(
|
||||
`INSERT INTO users (username, pass_hash, role) VALUES (?, ?, ?)`,
|
||||
username, passHash, role,
|
||||
)
|
||||
if err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return ErrUserExists
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUser fetches a single user by username.
|
||||
func (d *DB) GetUser(username string) (*User, error) {
|
||||
row := d.sql.QueryRow(
|
||||
`SELECT id, username, pass_hash, role, created_at FROM users WHERE username = ?`,
|
||||
username,
|
||||
)
|
||||
u := &User{}
|
||||
var createdAt string
|
||||
err := row.Scan(&u.ID, &u.Username, &u.PassHash, &u.Role, &createdAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// ListUsers returns all users ordered by id.
|
||||
func (d *DB) ListUsers() ([]*User, error) {
|
||||
rows, err := d.sql.Query(
|
||||
`SELECT id, username, role, created_at FROM users ORDER BY id`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []*User
|
||||
for rows.Next() {
|
||||
u := &User{}
|
||||
var createdAt string
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.Role, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
users = append(users, u)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteUser removes a user. Returns ErrUserNotFound if they don't exist.
|
||||
func (d *DB) DeleteUser(username string) error {
|
||||
res, err := d.sql.Exec(`DELETE FROM users WHERE username = ?`, username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdatePassword replaces the stored hash for a user.
|
||||
func (d *DB) UpdatePassword(username, passHash string) error {
|
||||
res, err := d.sql.Exec(
|
||||
`UPDATE users SET pass_hash = ? WHERE username = ?`,
|
||||
passHash, username,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserCount returns the total number of local accounts.
|
||||
func (d *DB) UserCount() (int, error) {
|
||||
var n int
|
||||
err := d.sql.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
@@ -287,6 +287,7 @@ func (s *Server) handleServerDirectories(w http.ResponseWriter, req gqlRequest,
|
||||
func (s *Server) sessionFromRequest(r *http.Request) *auth.Session {
|
||||
raw := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(raw, "Bearer ") {
|
||||
log.Printf("[auth] No Bearer token found in header")
|
||||
return nil
|
||||
}
|
||||
token := strings.TrimPrefix(raw, "Bearer ")
|
||||
@@ -296,9 +297,14 @@ func (s *Server) sessionFromRequest(r *http.Request) *auth.Session {
|
||||
s.mu.RUnlock()
|
||||
|
||||
if mgr == nil {
|
||||
log.Printf("[auth] authmgr is nil")
|
||||
return nil
|
||||
}
|
||||
sess, err := mgr.Validate(token)
|
||||
if err != nil {
|
||||
log.Printf("[auth] Token validation failed: %v", err)
|
||||
return nil
|
||||
}
|
||||
sess, _ := mgr.Validate(token)
|
||||
return sess
|
||||
}
|
||||
|
||||
@@ -517,11 +523,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, req gqlRequest) {
|
||||
log.Printf("[auth] login: %s (%s)", username, role)
|
||||
writeJSONObj(w, map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"login": map[string]interface{}{
|
||||
"token": token,
|
||||
"username": username,
|
||||
"role": role,
|
||||
},
|
||||
"login": token,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1213,3 +1215,5 @@ func (s *Server) handleDocumentAtCommit(w http.ResponseWriter, req gqlRequest, s
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
3
backend/test_db.go
Normal file
3
backend/test_db.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package main
|
||||
import ("database/sql"; "fmt"; "log"; _ "modernc.org/sqlite")
|
||||
func main() { db, _ := sql.Open("sqlite", "C:/data/db/archivum.db"); rows, err := db.Query("SELECT username, role, pass_hash FROM users"); if err != nil {log.Fatalln(err)}; for rows.Next() { var u, r, p string; rows.Scan(&u, &r, &p); fmt.Println(u, r) } }
|
||||
8868
frontend/package-lock.json
generated
Normal file
8868
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
135
frontend/src/components/FolderPicker.vue
Normal file
135
frontend/src/components/FolderPicker.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { gql } from '@/lib/gql'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
const currentPath = ref(props.modelValue)
|
||||
const directories = ref<{ name: string; path: string }[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchDirectories(path: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await gql<{ serverDirectories: { name: string; path: string }[] }>(
|
||||
`query Dirs($p: String) { serverDirectories(path: $p) { name path } }`,
|
||||
{ p: path || null }
|
||||
)
|
||||
directories.value = data.serverDirectories
|
||||
// Update input box to the fetched path if we passed empty and it resolved to root
|
||||
if (directories.value.length > 0 && path === '') {
|
||||
// Find parent or use current
|
||||
const parent = directories.value.find(d => d.name === '..')
|
||||
if (parent) currentPath.value = parent.path + '/.' // hacky, but fine
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
open.value = true
|
||||
currentPath.value = props.modelValue
|
||||
fetchDirectories(currentPath.value)
|
||||
}
|
||||
|
||||
function selectPath() {
|
||||
emit('update:modelValue', currentPath.value)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function navigate(toPath: string) {
|
||||
currentPath.value = toPath
|
||||
fetchDirectories(toPath)
|
||||
}
|
||||
|
||||
// Ensure local currentPath updates when props change externally
|
||||
watch(() => props.modelValue, (val) => {
|
||||
currentPath.value = val
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<label v-if="label" class="label">{{ label }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
:value="modelValue"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
class="input flex-1"
|
||||
:placeholder="placeholder"
|
||||
/>
|
||||
<button type="button" class="btn-secondary" @click="handleOpen">Browse...</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Modal -->
|
||||
<transition name="fade">
|
||||
<div v-if="open" class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 backdrop-blur-sm p-4">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 w-full max-w-lg overflow-hidden flex flex-col max-h-[80vh]">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">Select Folder</h3>
|
||||
<button type="button" @click="open = false" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="p-4 bg-slate-50 dark:bg-slate-900/50 flex-1 overflow-auto flex flex-col gap-3">
|
||||
<input
|
||||
v-model="currentPath"
|
||||
@keydown.enter.prevent="fetchDirectories(currentPath)"
|
||||
class="input font-mono text-sm"
|
||||
placeholder="Path..."
|
||||
/>
|
||||
|
||||
<div class="flex-1 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg overflow-y-auto min-h-[200px]">
|
||||
<div v-if="loading" class="p-4 text-center text-slate-400 animate-pulse text-sm">
|
||||
Loading...
|
||||
</div>
|
||||
<ul v-else class="divide-y divide-slate-100 dark:divide-slate-700/50">
|
||||
<li v-if="directories.length === 0" class="p-3 text-sm text-slate-500 text-center">
|
||||
No subdirectories
|
||||
</li>
|
||||
<li v-for="dir in directories" :key="dir.path">
|
||||
<button
|
||||
type="button"
|
||||
class="w-fulltext-left flex items-center gap-2 px-3 py-2 w-full hover:bg-accent-50 dark:hover:bg-accent-500/10 focus:bg-accent-50 focus:outline-none transition group"
|
||||
@click="navigate(dir.path)"
|
||||
>
|
||||
<svg class="w-4 h-4 text-accent-500 group-hover:text-accent-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||
<span class="text-sm font-medium text-slate-700 dark:text-slate-300 truncate">{{ dir.name }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="p-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-2 bg-white dark:bg-slate-800">
|
||||
<button type="button" class="btn-ghost" @click="open = false">Cancel</button>
|
||||
<button type="button" class="btn-primary" @click="selectPath">Select Reference</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
@@ -23,7 +23,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
)
|
||||
token.value = data.login
|
||||
username.value = user
|
||||
localStorage.setItem('token', data.login)
|
||||
localStorage.setItem('token', data.login); console.log('[login] token is:', data.login, localStorage.getItem('token'))
|
||||
localStorage.setItem('username', user)
|
||||
}
|
||||
|
||||
@@ -41,3 +41,4 @@ export const useAppStore = defineStore('app', () => {
|
||||
|
||||
return { requiresSetup, token, username, checkStatus, login, logout }
|
||||
})
|
||||
|
||||
|
||||
178
frontend/src/views/AdminView.vue
Normal file
178
frontend/src/views/AdminView.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { gql } from '@/lib/gql'
|
||||
import FolderPicker from '@/components/FolderPicker.vue'
|
||||
|
||||
const storagePath = ref('')
|
||||
const ldapEnabled = ref(false)
|
||||
const ldapHost = ref('')
|
||||
const ldapPort = ref(389)
|
||||
const ldapBaseDN = ref('')
|
||||
const ldapBindDN = ref('')
|
||||
const ldapBindPassword = ref('')
|
||||
|
||||
const oldPass = ref('')
|
||||
const newPass = ref('')
|
||||
|
||||
const loading = ref(true)
|
||||
const status = ref({ msg: '', isError: false })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await gql<{ config: any }>(`{ config { storagePath ldap { host port baseDN bindDN } } }`)
|
||||
storagePath.value = data.config.storagePath || ''
|
||||
|
||||
if (data.config.ldap) {
|
||||
ldapEnabled.value = true
|
||||
ldapHost.value = data.config.ldap.host
|
||||
ldapPort.value = data.config.ldap.port
|
||||
ldapBaseDN.value = data.config.ldap.baseDN
|
||||
ldapBindDN.value = data.config.ldap.bindDN
|
||||
}
|
||||
} catch (err: any) {
|
||||
status.value = { msg: err.message, isError: true }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function showStatus(msg: string, isError: boolean = false) {
|
||||
status.value = { msg, isError }
|
||||
setTimeout(() => status.value.msg = '', 4000)
|
||||
}
|
||||
|
||||
async function saveStorage() {
|
||||
try {
|
||||
await gql(`mutation UpdateStorage($path: String!) { updateStoragePath(path: $path) }`, { path: storagePath.value })
|
||||
showStatus('Storage path updated.')
|
||||
} catch (err: any) {
|
||||
showStatus(err.message, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLdap() {
|
||||
try {
|
||||
let input = null
|
||||
if (ldapEnabled.value) {
|
||||
input = {
|
||||
host: ldapHost.value,
|
||||
port: ldapPort.value,
|
||||
baseDN: ldapBaseDN.value,
|
||||
bindDN: ldapBindDN.value,
|
||||
bindPassword: ldapBindPassword.value
|
||||
}
|
||||
}
|
||||
|
||||
await gql(`mutation UpdateLdap($i: LDAPInput) { updateLdapConfig(input: $i) }`, { i: input })
|
||||
showStatus(ldapEnabled.value ? 'LDAP config updated.' : 'LDAP disabled.')
|
||||
} catch (err: any) {
|
||||
showStatus(err.message, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function savePassword() {
|
||||
if (newPass.value.length < 8) {
|
||||
showStatus('New password must be at least 8 characters.', true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await gql(`mutation ChangePass($old: String!, $new: String!) { changePassword(old: $old, new: $new) }`, { old: oldPass.value, new: newPass.value })
|
||||
showStatus('Password updated successfully.')
|
||||
oldPass.value = ''
|
||||
newPass.value = ''
|
||||
} catch (err: any) {
|
||||
showStatus(err.message, true)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 max-w-2xl mx-auto space-y-8">
|
||||
<h1 class="text-3xl font-bold mb-6">Admin Settings</h1>
|
||||
|
||||
<div v-if="status.msg" :class="[
|
||||
'px-4 py-3 rounded-lg text-sm border',
|
||||
status.isError ? 'bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 border-red-200 dark:border-red-500/20' : 'bg-green-50 dark:bg-green-500/10 text-green-600 dark:text-green-400 border-green-200 dark:border-green-500/20'
|
||||
]">
|
||||
{{ status.msg }}
|
||||
</div>
|
||||
|
||||
<!-- Storage Setup -->
|
||||
<section class="card p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 border-b border-slate-200 dark:border-slate-700 pb-2">Storage Path</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="label">Document Path</label>
|
||||
<p class="text-xs text-slate-500 mb-2">Where Archivum reads and writes .adoc files</p>
|
||||
<div class="flex gap-2">
|
||||
<FolderPicker v-model="storagePath" class="flex-1" placeholder="/var/data/wiki" />
|
||||
<button class="btn-primary flex-none" @click="saveStorage">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Admin Account -->
|
||||
<section class="card p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 border-b border-slate-200 dark:border-slate-700 pb-2">Admin Account</h2>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="sm:col-span-2">
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400 mb-2">Change your password (requires current password).</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Current Password</label>
|
||||
<input v-model="oldPass" type="password" class="input" placeholder="••••••••" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">New Password</label>
|
||||
<input v-model="newPass" type="password" class="input" placeholder="••••••••" />
|
||||
</div>
|
||||
<div class="sm:col-span-2 flex justify-end">
|
||||
<button class="btn-primary" @click="savePassword">Change Password</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- LDAP Setup -->
|
||||
<section class="card p-6">
|
||||
<div class="flex items-center justify-between border-b border-slate-200 dark:border-slate-700 pb-2 mb-4">
|
||||
<h2 class="text-lg font-semibold">LDAP Integration</h2>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="ldapEnabled" class="rounded border-slate-300 text-accent-600 focus:ring-accent-500" />
|
||||
<span class="text-sm font-medium">Enable LDAP</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="ldapEnabled" class="grid gap-4 sm:grid-cols-2 mb-4">
|
||||
<div class="sm:col-span-2 md:col-span-1">
|
||||
<label class="label">Host</label>
|
||||
<input v-model="ldapHost" class="input" placeholder="ldap.example.com" />
|
||||
</div>
|
||||
<div class="sm:col-span-2 md:col-span-1">
|
||||
<label class="label">Port</label>
|
||||
<input v-model.number="ldapPort" type="number" class="input" placeholder="389" />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="label">Base DN <span class="text-slate-400 text-xs font-normal">(e.g. dc=example,dc=com)</span></label>
|
||||
<input v-model="ldapBaseDN" class="input" placeholder="dc=example,dc=com" />
|
||||
</div>
|
||||
<div class="sm:col-span-2 md:col-span-1">
|
||||
<label class="label">Bind DN (Service Account)</label>
|
||||
<input v-model="ldapBindDN" class="input" placeholder="cn=reader,dc=example,dc=com" />
|
||||
</div>
|
||||
<div class="sm:col-span-2 md:col-span-1">
|
||||
<label class="label">Bind Password</label>
|
||||
<input v-model="ldapBindPassword" type="password" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button class="btn-primary" @click="saveLdap">
|
||||
{{ ldapEnabled ? 'Save LDAP Config' : 'Save & Disable LDAP' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
84
frontend/src/views/LoginView.vue
Normal file
84
frontend/src/views/LoginView.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const app = useAppStore()
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await app.login(username.value, password.value)
|
||||
} catch (err: any) {
|
||||
if (err instanceof Error) {
|
||||
error.value = err.message
|
||||
} else {
|
||||
error.value = 'Failed to sign in'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900 p-4">
|
||||
<div class="w-full max-w-md bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 p-8 space-y-6">
|
||||
|
||||
<!-- Logo -->
|
||||
<div class="flex items-center justify-center gap-2 font-bold text-slate-900 dark:text-white text-xl">
|
||||
<svg class="w-8 h-8 text-accent-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
Archivum
|
||||
</div>
|
||||
|
||||
<h2 class="text-center text-slate-500 dark:text-slate-400">Sign in to continue</h2>
|
||||
|
||||
<form @submit.prevent="submit" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">Username</label>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
required
|
||||
class="w-full px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-accent-500 text-slate-900 dark:text-white"
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1 text-slate-700 dark:text-slate-300">Password</label>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
class="w-full px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-600 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-accent-500 text-slate-900 dark:text-white"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-sm text-red-600 bg-red-50 dark:bg-red-500/10 dark:text-red-400 p-3 rounded-lg border border-red-200 dark:border-red-500/20">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full flex justify-center py-2.5 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-accent-600 hover:bg-accent-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="loading"
|
||||
>
|
||||
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
|
||||
</svg>
|
||||
{{ loading ? 'Signing in...' : 'Sign in' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Archivum",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
Reference in New Issue
Block a user