1
0
mirror of https://github.com/muety/wakapi.git synced 2023-08-10 21:12:56 +03:00

Compare commits

..

11 Commits
1.6.3 ... 1.7.4

17 changed files with 308 additions and 66 deletions

View File

@ -9,7 +9,7 @@ RUN cd /src && go build -o wakapi
# When running the application using `docker run`, you can pass environment variables # When running the application using `docker run`, you can pass environment variables
# to override config values from .env using `-e` syntax. # to override config values from .env using `-e` syntax.
# Available options are: # Available options are:
# WAKAPI_DB_TYPE # WAKAPI_DB_TYPE
# WAKAPI_DB_USER # WAKAPI_DB_USER
# WAKAPI_DB_PASSWORD # WAKAPI_DB_PASSWORD
@ -17,8 +17,7 @@ RUN cd /src && go build -o wakapi
# WAKAPI_DB_PORT # WAKAPI_DB_PORT
# WAKAPI_DB_NAME # WAKAPI_DB_NAME
# WAKAPI_PASSWORD_SALT # WAKAPI_PASSWORD_SALT
# WAKAPI_DEFAULT_USER_NAME # WAKAPI_BASE_PATH
# WAKAPI_DEFAULT_USER_PASSWORD
FROM debian FROM debian
WORKDIR /app WORKDIR /app
@ -30,8 +29,6 @@ ENV WAKAPI_DB_PASSWORD ''
ENV WAKAPI_DB_HOST '' ENV WAKAPI_DB_HOST ''
ENV WAKAPI_DB_NAME=/data/wakapi.db ENV WAKAPI_DB_NAME=/data/wakapi.db
ENV WAKAPI_PASSWORD_SALT '' ENV WAKAPI_PASSWORD_SALT ''
ENV WAKAPI_DEFAULT_USER_NAME admin
ENV WAKAPI_DEFAULT_USER_PASSWORD admin
COPY --from=build-env /src/wakapi /app/ COPY --from=build-env /src/wakapi /app/
COPY --from=build-env /src/config.ini /app/ COPY --from=build-env /src/config.ini /app/

34
main.go
View File

@ -43,6 +43,11 @@ func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile) log.SetFlags(log.LstdFlags | log.Lshortfile)
} }
// Show data loss warning
if config.CleanUp {
promptAbort("`CLEANUP` is set to `true`, which may cause data loss. Are you sure to continue?", 5)
}
// Connect to database // Connect to database
var err error var err error
db, err = gorm.Open(config.DbDialect, utils.MakeConnectionString(config)) db, err = gorm.Open(config.DbDialect, utils.MakeConnectionString(config))
@ -85,11 +90,13 @@ func main() {
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService) heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService)
summaryHandler := routes.NewSummaryHandler(summaryService) summaryHandler := routes.NewSummaryHandler(summaryService)
healthHandler := routes.NewHealthHandler(db) healthHandler := routes.NewHealthHandler(db)
settingsHandler := routes.NewSettingsHandler(userService)
publicHandler := routes.NewIndexHandler(userService, keyValueService) publicHandler := routes.NewIndexHandler(userService, keyValueService)
// Setup Routers // Setup Routers
router := mux.NewRouter() router := mux.NewRouter()
publicRouter := router.PathPrefix("/").Subrouter() publicRouter := router.PathPrefix("/").Subrouter()
settingsRouter := publicRouter.PathPrefix("/settings").Subrouter()
summaryRouter := publicRouter.PathPrefix("/summary").Subrouter() summaryRouter := publicRouter.PathPrefix("/summary").Subrouter()
apiRouter := router.PathPrefix("/api").Subrouter() apiRouter := router.PathPrefix("/api").Subrouter()
@ -105,17 +112,24 @@ func main() {
// Router configs // Router configs
router.Use(loggingMiddleware, recoveryMiddleware) router.Use(loggingMiddleware, recoveryMiddleware)
summaryRouter.Use(authenticateMiddleware) summaryRouter.Use(authenticateMiddleware)
settingsRouter.Use(authenticateMiddleware)
apiRouter.Use(corsMiddleware, authenticateMiddleware) apiRouter.Use(corsMiddleware, authenticateMiddleware)
// Public Routes // Public Routes
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(publicHandler.Index) publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(publicHandler.GetIndex)
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(publicHandler.Login) publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogin)
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(publicHandler.Logout) publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogout)
publicRouter.Path("/signup").Methods(http.MethodGet, http.MethodPost).HandlerFunc(publicHandler.Signup) publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(publicHandler.GetSignup)
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(publicHandler.Imprint) publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(publicHandler.PostSignup)
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(publicHandler.GetImprint)
// Summary Routes // Summary Routes
summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.Index) summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.GetIndex)
// Settings Routes
settingsRouter.Methods(http.MethodGet).HandlerFunc(settingsHandler.GetIndex)
settingsRouter.Path("/credentials").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCredentials)
settingsRouter.Path("/reset").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostResetApiKey)
// API Routes // API Routes
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost) apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
@ -163,3 +177,11 @@ func migrateLanguages() {
} }
} }
} }
func promptAbort(message string, timeoutSec int) {
log.Printf("[WARNING] %s.\nTo abort server startup, press Ctrl+C.\n", message)
for i := timeoutSec; i > 0; i-- {
log.Printf("Starting in %d seconds ...\n", i)
time.Sleep(1 * time.Second)
}
}

View File

@ -161,7 +161,11 @@ func readConfig() *Config {
port = cfg.Section("server").Key("port").MustInt() port = cfg.Section("server").Key("port").MustInt()
} }
basePathEnv, basePathEnvExists := os.LookupEnv("WAKAPI_BASE_PATH")
basePath := cfg.Section("server").Key("base_path").MustString("/") basePath := cfg.Section("server").Key("base_path").MustString("/")
if basePathEnvExists {
basePath = basePathEnv
}
if strings.HasSuffix(basePath, "/") { if strings.HasSuffix(basePath, "/") {
basePath = basePath[:len(basePath)-1] basePath = basePath[:len(basePath)-1]
} }

View File

@ -19,8 +19,27 @@ type Signup struct {
PasswordRepeat string `schema:"password_repeat"` PasswordRepeat string `schema:"password_repeat"`
} }
type CredentialsReset struct {
PasswordOld string `schema:"password_old"`
PasswordNew string `schema:"password_new"`
PasswordRepeat string `schema:"password_repeat"`
}
func (c *CredentialsReset) IsValid() bool {
return validatePassword(c.PasswordNew) &&
c.PasswordNew == c.PasswordRepeat
}
func (s *Signup) IsValid() bool { func (s *Signup) IsValid() bool {
return len(s.Username) >= 3 && return validateUsername(s.Username) &&
len(s.Password) >= 6 && validatePassword(s.Password) &&
s.Password == s.PasswordRepeat s.Password == s.PasswordRepeat
} }
func validateUsername(username string) bool {
return len(username) >= 3
}
func validatePassword(password string) bool {
return len(password) >= 6
}

View File

@ -55,5 +55,5 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
return return
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusCreated)
} }

View File

@ -29,7 +29,7 @@ func NewIndexHandler(userService *services.UserService, keyValueService *service
} }
} }
func (h *IndexHandler) Index(w http.ResponseWriter, r *http.Request) { func (h *IndexHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() { if h.config.IsDev() {
loadTemplates() loadTemplates()
} }
@ -43,18 +43,10 @@ func (h *IndexHandler) Index(w http.ResponseWriter, r *http.Request) {
return return
} }
// TODO: make this more generic and reusable
if success := r.URL.Query().Get("success"); success != "" {
templates["index.tpl.html"].Execute(w, struct {
Success string
Error string
}{Success: success})
return
}
templates["index.tpl.html"].Execute(w, nil) templates["index.tpl.html"].Execute(w, nil)
} }
func (h *IndexHandler) Imprint(w http.ResponseWriter, r *http.Request) { func (h *IndexHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() { if h.config.IsDev() {
loadTemplates() loadTemplates()
} }
@ -69,7 +61,7 @@ func (h *IndexHandler) Imprint(w http.ResponseWriter, r *http.Request) {
}{HtmlText: text}) }{HtmlText: text})
} }
func (h *IndexHandler) Login(w http.ResponseWriter, r *http.Request) { func (h *IndexHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() { if h.config.IsDev() {
loadTemplates() loadTemplates()
} }
@ -121,7 +113,7 @@ func (h *IndexHandler) Login(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound) http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
} }
func (h *IndexHandler) Logout(w http.ResponseWriter, r *http.Request) { func (h *IndexHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() { if h.config.IsDev() {
loadTemplates() loadTemplates()
} }
@ -130,27 +122,7 @@ func (h *IndexHandler) Logout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.BasePath), http.StatusFound) http.Redirect(w, r, fmt.Sprintf("%s/", h.config.BasePath), http.StatusFound)
} }
func (h *IndexHandler) Signup(w http.ResponseWriter, r *http.Request) { func (h *IndexHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
return
}
switch r.Method {
case http.MethodPost:
h.handlePostSignup(w, r)
return
default:
h.handleGetSignup(w, r)
return
}
}
func (h *IndexHandler) handleGetSignup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() { if h.config.IsDev() {
loadTemplates() loadTemplates()
} }
@ -167,7 +139,7 @@ func (h *IndexHandler) handleGetSignup(w http.ResponseWriter, r *http.Request) {
templates["signup.tpl.html"].Execute(w, nil) templates["signup.tpl.html"].Execute(w, nil)
} }
func (h *IndexHandler) handlePostSignup(w http.ResponseWriter, r *http.Request) { func (h *IndexHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() { if h.config.IsDev() {
loadTemplates() loadTemplates()
} }

View File

@ -64,7 +64,7 @@ func respondAlert(w http.ResponseWriter, error, success, tplName string, status
templates[tplName].Execute(w, struct { templates[tplName].Execute(w, struct {
Error string Error string
Success string Success string
}{Error: error}) }{Error: error, Success: success})
} }
// TODO: do better // TODO: do better

112
routes/settings.go Normal file
View File

@ -0,0 +1,112 @@
package routes
import (
"fmt"
"github.com/gorilla/schema"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"net/url"
)
type SettingsHandler struct {
config *models.Config
userSrvc *services.UserService
}
var credentialsDecoder = schema.NewDecoder()
func NewSettingsHandler(userService *services.UserService) *SettingsHandler {
return &SettingsHandler{
config: models.GetConfig(),
userSrvc: userService,
}
}
func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if handleAlerts(w, r, "settings.tpl.html") {
return
}
templates["settings.tpl.html"].Execute(w, nil)
}
func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
var credentials models.CredentialsReset
if err := r.ParseForm(); err != nil {
respondAlert(w, "missing parameters", "", "settings.tpl.html", http.StatusBadRequest)
return
}
if err := credentialsDecoder.Decode(&credentials, r.PostForm); err != nil {
respondAlert(w, "missing parameters", "", "settings.tpl.html", http.StatusBadRequest)
return
}
if !utils.CheckPasswordBcrypt(user, credentials.PasswordOld, h.config.PasswordSalt) {
respondAlert(w, "invalid credentials", "", "settings.tpl.html", http.StatusUnauthorized)
return
}
if !credentials.IsValid() {
respondAlert(w, "invalid parameters", "", "settings.tpl.html", http.StatusBadRequest)
return
}
user.Password = credentials.PasswordNew
if err := utils.HashPassword(user, h.config.PasswordSalt); err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
return
}
if _, err := h.userSrvc.Update(user); err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
return
}
login := &models.Login{
Username: user.ID,
Password: user.Password,
}
encoded, err := h.config.SecureCookie.Encode(models.AuthCookieKey, login)
if err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
return
}
cookie := &http.Cookie{
Name: models.AuthCookieKey,
Value: encoded,
Path: "/",
Secure: !h.config.InsecureCookies,
HttpOnly: true,
}
http.SetCookie(w, cookie)
msg := url.QueryEscape("password was updated successfully")
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.BasePath, msg), http.StatusFound)
}
func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
if _, err := h.userSrvc.ResetApiKey(user); err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
return
}
msg := url.QueryEscape(fmt.Sprintf("your new api key is: %s", user.ApiKey))
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.BasePath, msg), http.StatusFound)
}

View File

@ -42,7 +42,7 @@ func (h *SummaryHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
utils.RespondJSON(w, http.StatusOK, summary) utils.RespondJSON(w, http.StatusOK, summary)
} }
func (h *SummaryHandler) Index(w http.ResponseWriter, r *http.Request) { func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() { if h.config.IsDev() {
loadTemplates() loadTemplates()
} }

View File

@ -96,10 +96,25 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
} }
close(c) close(c)
realFrom, realTo := from, to
if len(existingSummaries) > 0 {
realFrom = existingSummaries[0].FromTime
realTo = existingSummaries[len(existingSummaries)-1].ToTime
}
if len(heartbeats) > 0 {
t1, t2 := time.Time(heartbeats[0].Time), time.Time(heartbeats[len(heartbeats)-1].Time)
if t1.After(realFrom) && t1.Before(time.Date(realFrom.Year(), realFrom.Month(), realFrom.Day()+1, 0, 0, 0, 0, realFrom.Location())) {
realFrom = t1
}
if t2.Before(realTo) && t2.After(time.Date(realTo.Year(), realTo.Month(), realTo.Day()-1, 0, 0, 0, 0, realTo.Location())) {
realTo = t2
}
}
aggregatedSummary := &models.Summary{ aggregatedSummary := &models.Summary{
UserID: user.ID, UserID: user.ID,
FromTime: from, FromTime: realFrom,
ToTime: to, ToTime: realTo,
Projects: projectItems, Projects: projectItems,
Languages: languageItems, Languages: languageItems,
Editors: editorItems, Editors: editorItems,
@ -134,6 +149,7 @@ func (srv *SummaryService) GetByUserWithin(user *models.User, from, to time.Time
Where(&models.Summary{UserID: user.ID}). Where(&models.Summary{UserID: user.ID}).
Where("from_time >= ?", from). Where("from_time >= ?", from).
Where("to_time <= ?", to). Where("to_time <= ?", to).
Order("from_time asc").
Preload("Projects", "type = ?", models.SummaryProject). Preload("Projects", "type = ?", models.SummaryProject).
Preload("Languages", "type = ?", models.SummaryLanguage). Preload("Languages", "type = ?", models.SummaryLanguage).
Preload("Editors", "type = ?", models.SummaryEditor). Preload("Editors", "type = ?", models.SummaryEditor).
@ -224,7 +240,16 @@ func getMissingIntervals(from, to time.Time, existingSummaries []*models.Summary
// Between // Between
for i := 0; i < len(existingSummaries)-1; i++ { for i := 0; i < len(existingSummaries)-1; i++ {
if existingSummaries[i].ToTime.Before(existingSummaries[i+1].FromTime) { t1, t2 := existingSummaries[i].ToTime, existingSummaries[i+1].FromTime
if t1.Equal(t2) {
continue
}
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
// one or more day missing in between?
if td1.Before(td2) {
intervals = append(intervals, &Interval{existingSummaries[i].ToTime, existingSummaries[i+1].FromTime}) intervals = append(intervals, &Interval{existingSummaries[i].ToTime, existingSummaries[i+1].FromTime})
} }
} }

View File

@ -82,6 +82,11 @@ func (srv *UserService) Update(user *models.User) (*models.User, error) {
return user, nil return user, nil
} }
func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
user.ApiKey = uuid.NewV4().String()
return srv.Update(user)
}
func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) { func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
user.Password = login.Password user.Password = login.Password
if err := utils.HashPassword(user, srv.Config.PasswordSalt); err != nil { if err := utils.HashPassword(user, srv.Config.PasswordSalt); err != nil {

View File

@ -15,12 +15,12 @@ func StartOfWeek() time.Time {
func StartOfMonth() time.Time { func StartOfMonth() time.Time {
ref := time.Now() ref := time.Now()
return time.Date(ref.Year(), ref.Month(), 0, 0, 0, 0, 0, ref.Location()) return time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, ref.Location())
} }
func StartOfYear() time.Time { func StartOfYear() time.Time {
ref := time.Now() ref := time.Now()
return time.Date(ref.Year(), time.January, 0, 0, 0, 0, 0, ref.Location()) return time.Date(ref.Year(), time.January, 1, 0, 0, 0, 0, ref.Location())
} }
// https://stackoverflow.com/a/18632496 // https://stackoverflow.com/a/18632496

View File

@ -1 +1 @@
1.6.3 1.7.4

View File

@ -5,7 +5,7 @@
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center"> <body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
<div class="flex items-center justify-between max-w-4xl flex-grow"> <div class="flex items-center justify-between max-w-4xl flex-grow">
<div><a href="" class="text-gray-500 text-sm">🠐 Go back</a></div> <div><a href="" class="text-gray-500 text-sm">&larr Go back</a></div>
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Imprint & Data Privacy</h1> <div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Imprint & Data Privacy</h1>
</div> </div>
<div></div> <div></div>

75
views/settings.tpl.html Normal file
View File

@ -0,0 +1,75 @@
<html>
{{ template "head.tpl.html" . }}
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
<div class="w-full flex justify-center">
<div class="flex items-center justify-between max-w-4xl flex-grow">
<div><a href="" class="text-gray-500 text-sm">&larr; Go back</a></div>
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Settings</h1></div>
<div></div>
</div>
</div>
{{ template "alerts.tpl.html" . }}
<main class="mt-4 flex-grow flex justify-center w-full">
<div class="flex flex-col flex-grow max-w-lg mt-8">
<div class="w-full my-8 pb-8 border-b border-gray-700">
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
Change Password
</div>
<form class="mt-10" action="settings/credentials" method="post">
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password_old">Current Password</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="password" id="password_old"
name="password_old" placeholder="Enter your old password" minlength="6" required>
</div>
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password_new">New Password</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="password" id="password_new"
name="password_new" placeholder="Choose a password" minlength="6" required>
</div>
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password_repeat">And again ...</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="password" id="password_repeat"
name="password_repeat" placeholder="Repeat your password" minlength="6" required>
</div>
<div class="flex justify-between float-right">
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
Save
</button>
</div>
</form>
</div>
<div class="w-full mt-4 mb-8 pb-8">
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
Reset API Key
</div>
<form class="mt-6" action="settings/reset" method="post">
<div class="text-gray-300 text-sm mb-4">
<strong>⚠️ Caution:</strong> Resetting your API key requires you to update your <span class="font-mono">.wakatime.cfg</span> files on all of your computers to make the WakaTime client send heartbeats again.
</div>
<div class="flex justify-between float-right">
<button type="submit" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm">
Reset
</button>
</div>
</form>
</div>
</div>
</main>
{{ template "footer.tpl.html" . }}
{{ template "foot.tpl.html" . }}
</body>
</html>

View File

@ -5,7 +5,7 @@
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center"> <body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
<div class="flex items-center justify-between max-w-4xl flex-grow"> <div class="flex items-center justify-between max-w-4xl flex-grow">
<div><a href="" class="text-gray-500 text-sm">🠐 Go back</a></div> <div><a href="" class="text-gray-500 text-sm">&larr Go back</a></div>
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Sign Up</h1></div> <div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Sign Up</h1></div>
<div></div> <div></div>
</div> </div>
@ -41,7 +41,7 @@
name="password" placeholder="Choose a password" minlength="6" required> name="password" placeholder="Choose a password" minlength="6" required>
</div> </div>
<div class="mb-8"> <div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password">And again ...</label> <label class="inline-block text-sm mb-1 text-gray-500" for="password_repeat">And again ...</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3" <input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="password" id="password_repeat" type="password" id="password_repeat"
name="password_repeat" placeholder="Repeat your password" minlength="6" required> name="password_repeat" placeholder="Repeat your password" minlength="6" required>

View File

@ -4,21 +4,32 @@
<body class="relative bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center"> <body class="relative bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
<div class="hidden flex bg-gray-800 shadow-md z-10 p-2 absolute top-0 right-0 mt-10 mr-8 border border-green-700 rounded popup" id="api-key-popup"> <div class="hidden flex bg-gray-800 shadow-md z-10 p-2 absolute top-0 right-0 mt-10 mr-8 border border-green-700 rounded popup"
id="api-key-popup">
<div class="flex-grow flex flex-col px-2"> <div class="flex-grow flex flex-col px-2">
<span class="text-xs text-gray-500 mx-1">API Key</span> <span class="text-xs text-gray-500 mx-1">API Key</span>
<input type="text" class="bg-transparent text-sm text-white mx-1 font-mono" id="api-key-container" readonly value="{{ .ApiKey }}" style="min-width: 330px"> <input type="text" class="bg-transparent text-sm text-white mx-1 font-mono" id="api-key-container" readonly
value="{{ .ApiKey }}" style="min-width: 330px">
</div> </div>
<div class="flex items-center px-2 border-l border-gray-700"> <div class="flex items-center px-2 border-l border-gray-700">
<button title="Copy to clipboard" onclick="copyApiKey(event)">📋</button> <button title="Copy to clipboard" onclick="copyApiKey(event)">📋</button>
</div> </div>
</div> </div>
<div class="absolute top-0 right-0 mr-8 mt-10 py-2"> <div class="absolute flex top-0 right-0 mr-8 mt-10 py-2">
<form action="logout" method="post"> <div class="mx-1">
<button type="button" class="mx-1 py-1 px-3 rounded border border-green-700 text-white text-sm" onclick="showApiKeyPopup(event)">🔐</button> <button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white text-sm"
<button type="submit" class="mx-1 py-1 px-3 rounded border border-green-700 text-white text-sm">Logout</button> onclick="showApiKeyPopup(event)">🔐
</form> </button>
</div>
<div class="mx-1">
<a href="settings" class="py-1 px-3 h-8 block rounded border border-green-700 text-white text-sm">⚙️</a>
</div>
<div class="mx-1">
<form action="logout" method="post">
<button type="submit" class="py-1 px-3 h-8 rounded border border-green-700 text-white text-sm">Logout</button>
</form>
</div>
</div> </div>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">