feat: people directory with rich profiles
This commit is contained in:
parent
d9f480be95
commit
19817691b2
1 changed files with 237 additions and 2 deletions
235
main.go
235
main.go
|
|
@ -56,6 +56,7 @@ type Settings struct {
|
|||
Name string `yaml:"name"`
|
||||
Subtitle string `yaml:"subtitle"`
|
||||
Logo string `yaml:"logo"`
|
||||
TopUser string `yaml:"top_user"`
|
||||
Language string `yaml:"language"`
|
||||
Timezone string `yaml:"timezone"`
|
||||
} `yaml:"company"`
|
||||
|
|
@ -81,6 +82,13 @@ type UserSettings struct {
|
|||
EmailPassword string `json:"email_password,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Messenger string `json:"messenger,omitempty"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
Manager string `json:"manager,omitempty"`
|
||||
JobTitle string `json:"job_title,omitempty"`
|
||||
Dept string `json:"dept,omitempty"`
|
||||
}
|
||||
|
||||
func loadUserSettings(username string) *UserSettings {
|
||||
|
|
@ -445,6 +453,13 @@ func userSettingsSaveHandler(w http.ResponseWriter, r *http.Request) {
|
|||
EmailPassword: r.FormValue("email_password"),
|
||||
Language: r.FormValue("language"),
|
||||
Timezone: r.FormValue("timezone"),
|
||||
Phone: r.FormValue("phone"),
|
||||
Address: r.FormValue("address"),
|
||||
Messenger: r.FormValue("messenger"),
|
||||
Bio: r.FormValue("bio"),
|
||||
Manager: r.FormValue("manager"),
|
||||
JobTitle: r.FormValue("job_title"),
|
||||
Dept: r.FormValue("dept"),
|
||||
}
|
||||
if err := saveUserSettings(user, us); err != nil {
|
||||
http.Error(w, "Failed to save", http.StatusInternalServerError)
|
||||
|
|
@ -453,6 +468,109 @@ func userSettingsSaveHandler(w http.ResponseWriter, r *http.Request) {
|
|||
w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
// --- People directory ---
|
||||
|
||||
type apiUser struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
func fetchAllUsers() ([]apiUser, error) {
|
||||
apiToken := os.Getenv("AUTHELIA_SECRET")
|
||||
req, _ := http.NewRequest("GET", "http://127.0.0.1:8080/api/users", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+apiToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var users []apiUser
|
||||
json.NewDecoder(resp.Body).Decode(&users)
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func peopleDirectoryHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// If path is /people/{username}, show profile
|
||||
username := strings.TrimPrefix(r.URL.Path, "/people/")
|
||||
if username != "" && username != "/" && !strings.Contains(username, "/") {
|
||||
peopleProfileHandler(w, r, username)
|
||||
return
|
||||
}
|
||||
|
||||
users, err := fetchAllUsers()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to fetch users", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
type Person struct {
|
||||
Username string
|
||||
DisplayName string
|
||||
JobTitle string
|
||||
Dept string
|
||||
ProfilePic string
|
||||
}
|
||||
var people []Person
|
||||
for _, u := range users {
|
||||
us := loadUserSettings(u.Username)
|
||||
displayName := u.DisplayName
|
||||
if us.FirstName != "" {
|
||||
displayName = us.FirstName + " " + us.LastName
|
||||
}
|
||||
people = append(people, Person{
|
||||
Username: u.Username,
|
||||
DisplayName: displayName,
|
||||
JobTitle: us.JobTitle,
|
||||
Dept: us.Dept,
|
||||
ProfilePic: us.ProfilePicture,
|
||||
})
|
||||
}
|
||||
|
||||
tmpl := template.Must(template.New("people").Funcs(template.FuncMap{"t": t, "initial": initials}).Parse(peopleHTML))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
tmpl.Execute(w, map[string]interface{}{"People": people, "Lang": "en"})
|
||||
}
|
||||
|
||||
func peopleProfileHandler(w http.ResponseWriter, r *http.Request, username string) {
|
||||
us := loadUserSettings(username)
|
||||
|
||||
var managerName string
|
||||
if us.Manager != "" {
|
||||
mgr := loadUserSettings(us.Manager)
|
||||
if mgr.FirstName != "" {
|
||||
managerName = mgr.FirstName + " " + mgr.LastName
|
||||
} else {
|
||||
managerName = us.Manager
|
||||
}
|
||||
}
|
||||
|
||||
tmpl := template.Must(template.New("profile").Funcs(template.FuncMap{"t": t, "initial": initials}).Parse(profileHTML))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
tmpl.Execute(w, map[string]interface{}{
|
||||
"Username": username,
|
||||
"Profile": us,
|
||||
"ManagerName": managerName,
|
||||
"Lang": "en",
|
||||
})
|
||||
}
|
||||
|
||||
func initials(s string) string {
|
||||
parts := strings.Fields(s)
|
||||
if len(parts) == 0 {
|
||||
return "?"
|
||||
}
|
||||
var result string
|
||||
for _, p := range parts {
|
||||
if len(p) > 0 {
|
||||
result += strings.ToUpper(string(p[0]))
|
||||
}
|
||||
}
|
||||
if len(result) > 2 {
|
||||
result = result[:2]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// --- API proxy for authelia-api ---
|
||||
|
||||
func apiProxyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -522,6 +640,7 @@ func globalSettingsSaveHandler(w http.ResponseWriter, r *http.Request) {
|
|||
settings.Company.Name = r.FormValue("company_name")
|
||||
settings.Company.Subtitle = r.FormValue("company_subtitle")
|
||||
settings.Company.Logo = r.FormValue("company_logo")
|
||||
settings.Company.TopUser = r.FormValue("top_user")
|
||||
settings.Company.Language = r.FormValue("language")
|
||||
settings.Company.Timezone = r.FormValue("timezone")
|
||||
settings.SMTP.Host = r.FormValue("smtp_host")
|
||||
|
|
@ -731,6 +850,7 @@ const launcherHTML = `<!DOCTYPE html>
|
|||
</header>
|
||||
<div class="user-banner">
|
||||
<span>{{t .Lang "welcome" "user" .DisplayName}}</span>
|
||||
<a href="/people">People</a>
|
||||
<a href="/settings">{{t .Lang "settings"}}</a>
|
||||
<a href="https://auth.nextwks.eu/logout">{{t .Lang "logout"}}</a>
|
||||
</div>
|
||||
|
|
@ -794,6 +914,17 @@ const settingsHTML = `<!DOCTYPE html>
|
|||
</div>
|
||||
<div class="field"><label>Profile Picture URL</label><input type="url" name="profile_picture" value="{{.UserSettings.ProfilePicture}}" placeholder="https://example.com/avatar.jpg"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Profile Details</h2>
|
||||
<div class="field-row">
|
||||
<div class="field"><label>Job Title</label><input type="text" name="job_title" value="{{.UserSettings.JobTitle}}"></div>
|
||||
<div class="field"><label>Department</label><input type="text" name="dept" value="{{.UserSettings.Dept}}"></div>
|
||||
</div>
|
||||
<div class="field"><label>Phone</label><input type="tel" name="phone" value="{{.UserSettings.Phone}}" placeholder="+49 123 456789"></div>
|
||||
<div class="field"><label>Address</label><input type="text" name="address" value="{{.UserSettings.Address}}" placeholder="Street, City, Country"></div>
|
||||
<div class="field"><label>Messenger</label><input type="text" name="messenger" value="{{.UserSettings.Messenger}}" placeholder="@user:signal / t.me/user"></div>
|
||||
<div class="field"><label>Bio</label><textarea name="bio" rows="3" style="width:100%;padding:0.5rem;border:1px solid #e2e8f0;border-radius:6px;font-size:0.88rem;">{{.UserSettings.Bio}}</textarea></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Mail</h2>
|
||||
<p class="field-note">Server: {{.Settings.IMAP.Host}}:{{.Settings.IMAP.Port}}</p>
|
||||
|
|
@ -852,6 +983,105 @@ const settingsHTML = `<!DOCTYPE html>
|
|||
</body>
|
||||
</html>`
|
||||
|
||||
const peopleHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>People — NextWorkspace</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f2f5; color: #1a1a2e; }
|
||||
header { background: linear-gradient(135deg, #1a1a2e, #16213e); color: #fff; padding: 1rem 1.5rem; display: flex; justify-content: space-between; align-items: center; }
|
||||
header h1 { font-size: 1.2rem; }
|
||||
header a { color: #63b3ed; text-decoration: none; font-size: 0.85rem; }
|
||||
.container { max-width: 1000px; margin: 0 auto; padding: 2rem; }
|
||||
input.search { width: 100%; padding: 0.75rem 1rem; border: 1px solid #e2e8f0; border-radius: 10px; font-size: 0.95rem; outline: none; margin-bottom: 1.5rem; }
|
||||
input.search:focus { border-color: #63b3ed; box-shadow: 0 0 0 2px rgba(99,179,237,0.15); }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
|
||||
.card { background: #fff; border-radius: 12px; padding: 1.5rem; text-align: center; text-decoration: none; color: inherit; box-shadow: 0 2px 8px rgba(0,0,0,0.08); transition: transform .2s; display: block; }
|
||||
.card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0,0,0,0.12); }
|
||||
.avatar { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; margin: 0 auto 0.75rem; display: block; }
|
||||
.avatar.initials { background: #1a1a2e; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; font-weight: 600; }
|
||||
.name { font-weight: 600; margin-bottom: 0.25rem; }
|
||||
.title { color: #718096; font-size: 0.85rem; }
|
||||
.dept { color: #a0aec0; font-size: 0.8rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>People</h1><a href="/home/">Back to Launcher</a></header>
|
||||
<div class="container">
|
||||
<input class="search" type="text" id="search" placeholder="Search by name or department..." oninput="filter(this.value)">
|
||||
<div class="grid" id="grid">
|
||||
{{range .People}}
|
||||
<a class="card" href="/people/{{.Username}}">
|
||||
{{if .ProfilePic}}<img src="{{.ProfilePic}}" class="avatar">{{else}}<div class="avatar initials">{{initial .DisplayName}}</div>{{end}}
|
||||
<div class="name">{{.DisplayName}}</div>
|
||||
{{if .JobTitle}}<div class="title">{{.JobTitle}}</div>{{end}}
|
||||
{{if .Dept}}<div class="dept">{{.Dept}}</div>{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function filter(q) { q=q.toLowerCase(); document.querySelectorAll('.card').forEach(c => { c.style.display = c.textContent.toLowerCase().includes(q) ? '' : 'none'; }); }
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const profileHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Profile — NextWorkspace</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f2f5; color: #1a1a2e; }
|
||||
header { background: linear-gradient(135deg, #1a1a2e, #16213e); color: #fff; padding: 1rem 1.5rem; display: flex; justify-content: space-between; align-items: center; }
|
||||
header h1 { font-size: 1.2rem; }
|
||||
header a { color: #63b3ed; text-decoration: none; font-size: 0.85rem; }
|
||||
.container { max-width: 700px; margin: 2rem auto; padding: 0 1rem; }
|
||||
.back { color: #63b3ed; text-decoration: none; font-size: 0.9rem; display: inline-block; margin-bottom: 1.5rem; }
|
||||
.header { display: flex; gap: 1.5rem; align-items: center; margin-bottom: 2rem; }
|
||||
.avatar { width: 80px; height: 80px; border-radius: 50%; object-fit: cover; }
|
||||
.avatar.initials { background: #1a1a2e; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 2rem; font-weight: 600; min-width: 80px; }
|
||||
.header h2 { font-size: 1.5rem; margin-bottom: 0.25rem; }
|
||||
.header .sub { color: #718096; font-size: 0.9rem; }
|
||||
.card { background: #fff; border-radius: 10px; padding: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.05); border: 1px solid #edf2f7; }
|
||||
.row { padding: 0.75rem 0; border-bottom: 1px solid #f7fafc; display: flex; gap: 1rem; }
|
||||
.row:last-child { border: none; }
|
||||
.row .label { width: 100px; color: #718096; font-weight: 500; font-size: 0.85rem; flex-shrink: 0; }
|
||||
.row .value { color: #4a5568; font-size: 0.9rem; }
|
||||
.row a { color: #63b3ed; text-decoration: none; }
|
||||
.row a:hover { text-decoration: underline; }
|
||||
.bio { line-height: 1.6; color: #4a5568; font-size: 0.9rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>Profile</h1><a href="/people">Back to People</a></header>
|
||||
<div class="container">
|
||||
<a class="back" href="/people">← All People</a>
|
||||
<div class="header">
|
||||
{{if .Profile.ProfilePicture}}<img src="{{.Profile.ProfilePicture}}" class="avatar">{{else}}<div class="avatar initials">{{initial .Profile.FirstName}} {{initial .Profile.LastName}}</div>{{end}}
|
||||
<div>
|
||||
<h2>{{.Profile.FirstName}} {{.Profile.LastName}}</h2>
|
||||
{{if .Profile.JobTitle}}<div class="sub">{{.Profile.JobTitle}}</div>{{end}}
|
||||
{{if .Profile.Dept}}<div class="sub">{{.Profile.Dept}}</div>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
{{if .Profile.Email}}<div class="row"><div class="label">Email</div><div class="value"><a href="mailto:{{.Profile.Email}}">{{.Profile.Email}}</a></div></div>{{end}}
|
||||
{{if .Profile.Phone}}<div class="row"><div class="label">Phone</div><div class="value"><a href="tel:{{.Profile.Phone}}">{{.Profile.Phone}}</a></div></div>{{end}}
|
||||
{{if .Profile.Address}}<div class="row"><div class="label">Address</div><div class="value">{{.Profile.Address}}</div></div>{{end}}
|
||||
{{if .Profile.Messenger}}<div class="row"><div class="label">Messenger</div><div class="value">{{.Profile.Messenger}}</div></div>{{end}}
|
||||
{{if .ManagerName}}<div class="row"><div class="label">Manager</div><div class="value"><a href="/people/{{.Profile.Manager}}">{{.ManagerName}}</a></div></div>{{end}}
|
||||
{{if .Profile.Bio}}<div class="row"><div class="label">Bio</div><div class="value bio">{{.Profile.Bio}}</div></div>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const globalSettingsHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
|
|
@ -1093,6 +1323,7 @@ const adminHTML = `<!DOCTYPE html>
|
|||
<div class="field"><label>{{t .Lang "company_name"}}</label><input type="text" name="company_name" value="{{.Settings.Company.Name}}"></div>
|
||||
<div class="field"><label>{{t .Lang "company_subtitle"}}</label><input type="text" name="company_subtitle" value="{{.Settings.Company.Subtitle}}"></div>
|
||||
<div class="field"><label>{{t .Lang "company_logo"}}</label><input type="url" name="company_logo" value="{{.Settings.Company.Logo}}" placeholder="https://example.com/logo.png"></div>
|
||||
<div class="field"><label>Top-Level User (CEO)</label><input type="text" name="top_user" value="{{.Settings.Company.TopUser}}" placeholder="master"></div>
|
||||
<div class="field-row">
|
||||
<div class="field"><label>{{t .Lang "language"}}</label><select name="language"><option value="en" {{if eq .Settings.Company.Language "en"}}selected{{end}}>English</option><option value="de" {{if eq .Settings.Company.Language "de"}}selected{{end}}>Deutsch</option></select></div>
|
||||
<div class="field"><label>{{t .Lang "timezone"}}</label><select name="timezone">{{$tz := .Settings.Company.Timezone}}<option value="UTC" {{if eq $tz "UTC"}}selected{{end}}>UTC</option><option value="Europe/London" {{if eq $tz "Europe/London"}}selected{{end}}>Europe/London</option><option value="Europe/Berlin" {{if eq $tz "Europe/Berlin"}}selected{{end}}>Europe/Berlin</option><option value="America/New_York" {{if eq $tz "America/New_York"}}selected{{end}}>America/New_York</option><option value="Asia/Tokyo" {{if eq $tz "Asia/Tokyo"}}selected{{end}}>Asia/Tokyo</option></select></div>
|
||||
|
|
@ -1339,6 +1570,10 @@ func main() {
|
|||
mux.Handle("/settings", authMiddleware(settingsHandler))
|
||||
mux.Handle("/settings/save", authMiddleware(http.HandlerFunc(userSettingsSaveHandler)))
|
||||
|
||||
// People directory
|
||||
mux.Handle("/people", authMiddleware(http.HandlerFunc(peopleDirectoryHandler)))
|
||||
mux.Handle("/people/", authMiddleware(http.HandlerFunc(peopleDirectoryHandler)))
|
||||
|
||||
// Protected: admin — admins only
|
||||
mux.Handle("/config", adminGroupMiddleware(authMiddleware(adminHandler)))
|
||||
mux.Handle("/config/", adminGroupMiddleware(authMiddleware(adminHandler)))
|
||||
|
|
|
|||
Loading…
Reference in a new issue