Dodato kreiranje projekta kroz UI i change-password template
All checks were successful
Tests / unit-tests (push) Successful in 25s
All checks were successful
Tests / unit-tests (push) Successful in 25s
- CreateProject() sa validacijom imena (regex, duplikati, prazno) - POST /projects/create ruta sa AuthMiddleware - Modal forma na projects stranici za unos imena - 10 unit testova za CreateProject - change-password.html template i testovi - Ažuriran TESTING.md sa novom sekcijom Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3bb8d289af
commit
6c0ca3a96f
21
TESTING.md
21
TESTING.md
@ -14,6 +14,15 @@
|
|||||||
- [ ] Projekat bez README.md prikazuje "Bez opisa"
|
- [ ] Projekat bez README.md prikazuje "Bez opisa"
|
||||||
- [ ] Klik na projekat → otvara chat
|
- [ ] Klik na projekat → otvara chat
|
||||||
|
|
||||||
|
## Kreiranje projekta
|
||||||
|
- [ ] Klik na "Novi projekat" → pojavi se modal sa formom
|
||||||
|
- [ ] Unos validnog imena → projekat kreiran, vidljiv u listi
|
||||||
|
- [ ] Unos duplog imena → error poruka "već postoji"
|
||||||
|
- [ ] Unos praznog imena → browser validacija (required)
|
||||||
|
- [ ] Unos specijalnih karaktera → error poruka
|
||||||
|
- [ ] Klik van modala → modal se zatvara
|
||||||
|
- [ ] Klik na "Otkaži" → modal se zatvara
|
||||||
|
|
||||||
## Chat
|
## Chat
|
||||||
- [ ] Chat stranica se otvori sa WebSocket konekcijom
|
- [ ] Chat stranica se otvori sa WebSocket konekcijom
|
||||||
- [ ] Pošalji poruku → prikazuje se user poruka
|
- [ ] Pošalji poruku → prikazuje se user poruka
|
||||||
@ -23,6 +32,18 @@
|
|||||||
- [ ] Enter → šalje poruku, Shift+Enter → novi red
|
- [ ] Enter → šalje poruku, Shift+Enter → novi red
|
||||||
- [ ] Input se čisti posle slanja
|
- [ ] Input se čisti posle slanja
|
||||||
|
|
||||||
|
## Promena lozinke
|
||||||
|
- [ ] Pristup /change-password bez logina → redirect na /login
|
||||||
|
- [ ] Prikaz forme sa 3 polja (trenutna, nova, potvrda)
|
||||||
|
- [ ] Pogrešna trenutna lozinka → error poruka
|
||||||
|
- [ ] Nova lozinka kraća od 6 karaktera → error poruka
|
||||||
|
- [ ] Nova lozinka i potvrda se ne poklapaju → error poruka
|
||||||
|
- [ ] Uspešna promena → success poruka
|
||||||
|
- [ ] Login sa novom lozinkom → uspešan
|
||||||
|
- [ ] Login sa starom lozinkom → neuspešan
|
||||||
|
- [ ] Link "Promeni lozinku" vidljiv na /projects stranici
|
||||||
|
- [ ] Link "Nazad na projekte" vraća na /projects
|
||||||
|
|
||||||
## Sesija persistence
|
## Sesija persistence
|
||||||
- [ ] Zatvori tab → otvori ponovo → sesija živa, poruke replayed
|
- [ ] Zatvori tab → otvori ponovo → sesija živa, poruke replayed
|
||||||
- [ ] Idle sesija se čisti posle 30 minuta
|
- [ ] Idle sesija se čisti posle 30 minuta
|
||||||
|
|||||||
258
change_password_test.go
Normal file
258
change_password_test.go
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTestApp(t *testing.T) (cleanupFn func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
data := `{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "secret123",
|
||||||
|
"session_secret": "test-secret",
|
||||||
|
"projects_path": "/tmp"
|
||||||
|
}`
|
||||||
|
os.WriteFile(cfgPath, []byte(data), 0644)
|
||||||
|
|
||||||
|
var err error
|
||||||
|
oldCfg := cfg
|
||||||
|
oldTemplates := templates
|
||||||
|
oldSessionMgr := sessionMgr
|
||||||
|
|
||||||
|
cfg, err = LoadConfig(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
templates, err = NewTemplateRenderer("templates")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Templates: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionMgr = NewSessionManager(cfg.SessionSecret)
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
cfg = oldCfg
|
||||||
|
templates = oldTemplates
|
||||||
|
sessionMgr = oldSessionMgr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChangePasswordPage(t *testing.T) {
|
||||||
|
cleanup := setupTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
sess := sessionMgr.Create("admin")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("GET", "/change-password", nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sess.Token})
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handleChangePasswordPage(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "Promena lozinke") {
|
||||||
|
t.Error("expected page title in response")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "current_password") {
|
||||||
|
t.Error("expected current_password field")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChangePassword(t *testing.T) {
|
||||||
|
t.Run("uspešna promena", func(t *testing.T) {
|
||||||
|
cleanup := setupTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
form := url.Values{
|
||||||
|
"current_password": {"secret123"},
|
||||||
|
"new_password": {"newpass123"},
|
||||||
|
"confirm_password": {"newpass123"},
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest("POST", "/change-password", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handleChangePassword(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "uspešno promenjena") {
|
||||||
|
t.Error("expected success message")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nova šifra radi
|
||||||
|
if !cfg.CheckPassword("newpass123") {
|
||||||
|
t.Error("new password should work")
|
||||||
|
}
|
||||||
|
// Stara ne radi
|
||||||
|
if cfg.CheckPassword("secret123") {
|
||||||
|
t.Error("old password should not work")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("pogrešna trenutna lozinka", func(t *testing.T) {
|
||||||
|
cleanup := setupTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
form := url.Values{
|
||||||
|
"current_password": {"wrongpass"},
|
||||||
|
"new_password": {"newpass123"},
|
||||||
|
"confirm_password": {"newpass123"},
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest("POST", "/change-password", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handleChangePassword(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "Pogrešna trenutna lozinka") {
|
||||||
|
t.Error("expected wrong password error message")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("prekratka nova lozinka", func(t *testing.T) {
|
||||||
|
cleanup := setupTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
form := url.Values{
|
||||||
|
"current_password": {"secret123"},
|
||||||
|
"new_password": {"abc"},
|
||||||
|
"confirm_password": {"abc"},
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest("POST", "/change-password", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handleChangePassword(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "najmanje 6 karaktera") {
|
||||||
|
t.Error("expected min length error message")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("lozinke se ne poklapaju", func(t *testing.T) {
|
||||||
|
cleanup := setupTestApp(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
form := url.Values{
|
||||||
|
"current_password": {"secret123"},
|
||||||
|
"new_password": {"newpass123"},
|
||||||
|
"confirm_password": {"different"},
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest("POST", "/change-password", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handleChangePassword(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want %d", w.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "ne poklapaju") {
|
||||||
|
t.Error("expected mismatch error message")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigCheckPassword(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
|
||||||
|
data := `{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "testpass",
|
||||||
|
"session_secret": "abc123"
|
||||||
|
}`
|
||||||
|
os.WriteFile(cfgPath, []byte(data), 0644)
|
||||||
|
|
||||||
|
c, err := LoadConfig(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Šifra je hešovana u fajlu
|
||||||
|
if !isHashed(c.Password) {
|
||||||
|
t.Error("password should be hashed after LoadConfig")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckPassword radi
|
||||||
|
if !c.CheckPassword("testpass") {
|
||||||
|
t.Error("CheckPassword should return true for correct password")
|
||||||
|
}
|
||||||
|
if c.CheckPassword("wrong") {
|
||||||
|
t.Error("CheckPassword should return false for wrong password")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigSetPassword(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(dir, "config.json")
|
||||||
|
|
||||||
|
data := `{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "original",
|
||||||
|
"session_secret": "abc123"
|
||||||
|
}`
|
||||||
|
os.WriteFile(cfgPath, []byte(data), 0644)
|
||||||
|
|
||||||
|
c, err := LoadConfig(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.SetPassword("newpassword"); err != nil {
|
||||||
|
t.Fatalf("SetPassword: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.CheckPassword("newpassword") {
|
||||||
|
t.Error("new password should work")
|
||||||
|
}
|
||||||
|
if c.CheckPassword("original") {
|
||||||
|
t.Error("old password should not work")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proveri da je sačuvano u fajl
|
||||||
|
c2, err := LoadConfig(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadConfig after SetPassword: %v", err)
|
||||||
|
}
|
||||||
|
if !c2.CheckPassword("newpassword") {
|
||||||
|
t.Error("password should persist after reload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsHashed(t *testing.T) {
|
||||||
|
if isHashed("plaintext") {
|
||||||
|
t.Error("plaintext should not be detected as hashed")
|
||||||
|
}
|
||||||
|
if !isHashed("$2a$10$abcdefghijklmnopqrstuuABC") {
|
||||||
|
t.Error("$2a$ prefix should be detected as hashed")
|
||||||
|
}
|
||||||
|
if !isHashed("$2b$10$abcdefghijklmnopqrstuuABC") {
|
||||||
|
t.Error("$2b$ prefix should be detected as hashed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -34,6 +34,54 @@ func TestLoadConfig(t *testing.T) {
|
|||||||
if cfg.ProjectsPath != "/tmp/projects" {
|
if cfg.ProjectsPath != "/tmp/projects" {
|
||||||
t.Errorf("projects_path = %q, want /tmp/projects", cfg.ProjectsPath)
|
t.Errorf("projects_path = %q, want /tmp/projects", cfg.ProjectsPath)
|
||||||
}
|
}
|
||||||
|
if !cfg.CheckPassword("secret") {
|
||||||
|
t.Error("CheckPassword should return true for original password")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("password hashed on load", func(t *testing.T) {
|
||||||
|
data := `{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "myplainpass",
|
||||||
|
"session_secret": "abc123"
|
||||||
|
}`
|
||||||
|
os.WriteFile(cfgPath, []byte(data), 0644)
|
||||||
|
|
||||||
|
cfg, err := LoadConfig(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !isHashed(cfg.Password) {
|
||||||
|
t.Error("password should be hashed after load")
|
||||||
|
}
|
||||||
|
if !cfg.CheckPassword("myplainpass") {
|
||||||
|
t.Error("CheckPassword should work with original password")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("already hashed password not re-hashed", func(t *testing.T) {
|
||||||
|
// Prvo učitaj da se hešuje
|
||||||
|
data := `{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "testpass",
|
||||||
|
"session_secret": "abc123"
|
||||||
|
}`
|
||||||
|
os.WriteFile(cfgPath, []byte(data), 0644)
|
||||||
|
|
||||||
|
cfg1, err := LoadConfig(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first load: %v", err)
|
||||||
|
}
|
||||||
|
hash1 := cfg1.Password
|
||||||
|
|
||||||
|
// Ponovo učitaj — heš treba da ostane isti
|
||||||
|
cfg2, err := LoadConfig(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg2.Password != hash1 {
|
||||||
|
t.Error("already hashed password should not be re-hashed")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("defaults applied", func(t *testing.T) {
|
t.Run("defaults applied", func(t *testing.T) {
|
||||||
|
|||||||
19
main.go
19
main.go
@ -46,6 +46,7 @@ func main() {
|
|||||||
|
|
||||||
// Protected routes
|
// Protected routes
|
||||||
mux.Handle("GET /projects", AuthMiddleware(sessionMgr, http.HandlerFunc(handleProjects)))
|
mux.Handle("GET /projects", AuthMiddleware(sessionMgr, http.HandlerFunc(handleProjects)))
|
||||||
|
mux.Handle("POST /projects/create", AuthMiddleware(sessionMgr, http.HandlerFunc(handleCreateProject)))
|
||||||
mux.Handle("GET /chat/{project}", AuthMiddleware(sessionMgr, http.HandlerFunc(handleChat)))
|
mux.Handle("GET /chat/{project}", AuthMiddleware(sessionMgr, http.HandlerFunc(handleChat)))
|
||||||
mux.Handle("GET /ws", AuthMiddleware(sessionMgr, wsHandler))
|
mux.Handle("GET /ws", AuthMiddleware(sessionMgr, wsHandler))
|
||||||
mux.Handle("GET /api/file", AuthMiddleware(sessionMgr, http.HandlerFunc(handleFileAPI)))
|
mux.Handle("GET /api/file", AuthMiddleware(sessionMgr, http.HandlerFunc(handleFileAPI)))
|
||||||
@ -111,6 +112,24 @@ func handleProjects(w http.ResponseWriter, r *http.Request) {
|
|||||||
templates.Render(w, "projects.html", data)
|
templates.Render(w, "projects.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.FormValue("name")
|
||||||
|
|
||||||
|
if err := CreateProject(cfg.ProjectsPath, name); err != nil {
|
||||||
|
projects, _ := ListProjects(cfg.ProjectsPath)
|
||||||
|
data := map[string]any{
|
||||||
|
"Projects": projects,
|
||||||
|
"ProjectsPath": cfg.ProjectsPath,
|
||||||
|
"Error": err.Error(),
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
templates.Render(w, "projects.html", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(w, r, "/projects", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
func handleChat(w http.ResponseWriter, r *http.Request) {
|
func handleChat(w http.ResponseWriter, r *http.Request) {
|
||||||
project := r.PathValue("project")
|
project := r.PathValue("project")
|
||||||
if project == "" {
|
if project == "" {
|
||||||
|
|||||||
24
projects.go
24
projects.go
@ -1,8 +1,10 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@ -62,3 +64,25 @@ func ListProjects(projectsPath string) ([]Project, error) {
|
|||||||
|
|
||||||
return projects, nil
|
return projects, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var validProjectName = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)
|
||||||
|
|
||||||
|
// CreateProject creates a new empty project directory.
|
||||||
|
// Name must contain only alphanumeric characters, hyphens, and underscores.
|
||||||
|
func CreateProject(projectsPath, name string) error {
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("ime projekta ne može biti prazno")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !validProjectName.MatchString(name) {
|
||||||
|
return fmt.Errorf("ime projekta može sadržati samo slova, brojeve, '-' i '_'")
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(projectsPath, name)
|
||||||
|
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return fmt.Errorf("projekat '%s' već postoji", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.MkdirAll(path, 0755)
|
||||||
|
}
|
||||||
|
|||||||
108
projects_test.go
108
projects_test.go
@ -79,6 +79,18 @@ func TestListProjects(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("excludes files", func(t *testing.T) {
|
||||||
|
projects, err := ListProjects(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListProjects: %v", err)
|
||||||
|
}
|
||||||
|
for _, p := range projects {
|
||||||
|
if p.Name == "file.txt" {
|
||||||
|
t.Error("should not include files")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("nonexistent directory", func(t *testing.T) {
|
t.Run("nonexistent directory", func(t *testing.T) {
|
||||||
_, err := ListProjects("/nonexistent/path")
|
_, err := ListProjects("/nonexistent/path")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@ -97,3 +109,99 @@ func TestListProjects(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateProject(t *testing.T) {
|
||||||
|
t.Run("valid name creates directory", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
err := CreateProject(dir, "my-project")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProject: %v", err)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(filepath.Join(dir, "my-project"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("directory not created: %v", err)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
t.Error("expected directory, got file")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid name with underscore", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
err := CreateProject(dir, "my_project_2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProject: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "my_project_2")); err != nil {
|
||||||
|
t.Fatalf("directory not created: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("duplicate name returns error", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
os.MkdirAll(filepath.Join(dir, "existing"), 0755)
|
||||||
|
err := CreateProject(dir, "existing")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for duplicate name")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty name returns error", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
err := CreateProject(dir, "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for empty name")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("dot returns error", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
err := CreateProject(dir, ".")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for '.'")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("dotdot returns error", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
err := CreateProject(dir, "..")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for '..'")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("hidden name returns error", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
err := CreateProject(dir, ".secret")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for hidden name")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("special characters return error", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
invalid := []string{"foo bar", "foo/bar", "foo..bar", "a@b", "a!b", "проект"}
|
||||||
|
for _, name := range invalid {
|
||||||
|
err := CreateProject(dir, name)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error for name %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("name starting with hyphen returns error", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
err := CreateProject(dir, "-project")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for name starting with hyphen")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("name starting with underscore returns error", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
err := CreateProject(dir, "_project")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for name starting with underscore")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@ -105,6 +105,7 @@ a:hover {
|
|||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
@ -114,6 +115,7 @@ a:hover {
|
|||||||
|
|
||||||
.btn-secondary:hover {
|
.btn-secondary:hover {
|
||||||
background: var(--border);
|
background: var(--border);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-full {
|
.btn-full {
|
||||||
@ -620,6 +622,49 @@ a:hover {
|
|||||||
background: var(--text-muted);
|
background: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
box-shadow: 0 8px 32px var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box h2 {
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-hint {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Hidden */
|
/* Hidden */
|
||||||
.hidden {
|
.hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
|
|||||||
40
templates/change-password.html
Normal file
40
templates/change-password.html
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="sr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Claude Web Chat — Promena lozinke</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-box">
|
||||||
|
<h1>Promena lozinke</h1>
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="error-msg">{{.Error}}</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .Success}}
|
||||||
|
<div class="success-msg">{{.Success}}</div>
|
||||||
|
{{end}}
|
||||||
|
<form method="POST" action="/change-password">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="current_password">Trenutna lozinka</label>
|
||||||
|
<input type="password" id="current_password" name="current_password" autocomplete="current-password" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new_password">Nova lozinka</label>
|
||||||
|
<input type="password" id="new_password" name="new_password" autocomplete="new-password" required minlength="6">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirm_password">Potvrdi novu lozinku</label>
|
||||||
|
<input type="password" id="confirm_password" name="confirm_password" autocomplete="new-password" required minlength="6">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-full">Promeni lozinku</button>
|
||||||
|
</form>
|
||||||
|
<div class="form-footer">
|
||||||
|
<a href="/projects">← Nazad na projekte</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -10,8 +10,33 @@
|
|||||||
<div class="projects-container">
|
<div class="projects-container">
|
||||||
<div class="projects-header">
|
<div class="projects-header">
|
||||||
<h1>Projekti</h1>
|
<h1>Projekti</h1>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-secondary" onclick="document.getElementById('createModal').classList.remove('hidden')">Novi projekat</button>
|
||||||
|
<a href="/change-password" class="btn btn-secondary">Promeni lozinku</a>
|
||||||
<a href="/logout" class="btn">Odjavi se</a>
|
<a href="/logout" class="btn">Odjavi se</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Error}}
|
||||||
|
<div class="error-msg">{{.Error}}</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div id="createModal" class="modal-overlay hidden" onclick="if(event.target===this)this.classList.add('hidden')">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h2>Novi projekat</h2>
|
||||||
|
<form method="POST" action="/projects/create">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="projectName">Ime projekta</label>
|
||||||
|
<input type="text" id="projectName" name="name" placeholder="moj-projekat" required autofocus>
|
||||||
|
</div>
|
||||||
|
<p class="modal-hint">Dozvoljeni karakteri: slova, brojevi, - i _</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="document.getElementById('createModal').classList.add('hidden')">Otkaži</button>
|
||||||
|
<button type="submit" class="btn">Kreiraj</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{{if .Projects}}
|
{{if .Projects}}
|
||||||
<div class="projects-grid">
|
<div class="projects-grid">
|
||||||
{{range .Projects}}
|
{{range .Projects}}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user