mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
feat: option to publicly share stats data (resolve #36)
This commit is contained in:
parent
d1dc73b5e6
commit
fca12f522f
@ -25,8 +25,9 @@ func NewAuthenticateMiddleware(userService services.IUserService) *AuthenticateM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *AuthenticateMiddleware) WithOptionalFor(paths []string) {
|
func (m *AuthenticateMiddleware) WithOptionalFor(paths []string) *AuthenticateMiddleware {
|
||||||
m.optionalForPaths = paths
|
m.optionalForPaths = paths
|
||||||
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler {
|
func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler {
|
||||||
|
@ -24,7 +24,7 @@ func TestAuthenticateMiddleware_tryGetUserByApiKey_Success(t *testing.T) {
|
|||||||
userServiceMock := new(mocks.UserServiceMock)
|
userServiceMock := new(mocks.UserServiceMock)
|
||||||
userServiceMock.On("GetUserByKey", testApiKey).Return(testUser, nil)
|
userServiceMock.On("GetUserByKey", testApiKey).Return(testUser, nil)
|
||||||
|
|
||||||
sut := NewAuthenticateMiddleware(userServiceMock, []string{})
|
sut := NewAuthenticateMiddleware(userServiceMock)
|
||||||
|
|
||||||
result, err := sut.tryGetUserByApiKey(mockRequest)
|
result, err := sut.tryGetUserByApiKey(mockRequest)
|
||||||
|
|
||||||
@ -45,7 +45,7 @@ func TestAuthenticateMiddleware_tryGetUserByApiKey_InvalidHeader(t *testing.T) {
|
|||||||
|
|
||||||
userServiceMock := new(mocks.UserServiceMock)
|
userServiceMock := new(mocks.UserServiceMock)
|
||||||
|
|
||||||
sut := NewAuthenticateMiddleware(userServiceMock, []string{})
|
sut := NewAuthenticateMiddleware(userServiceMock)
|
||||||
|
|
||||||
result, err := sut.tryGetUserByApiKey(mockRequest)
|
result, err := sut.tryGetUserByApiKey(mockRequest)
|
||||||
|
|
||||||
|
51
migrations/20210206_drop_badges_column_add_sharing_flags.go
Normal file
51
migrations/20210206_drop_badges_column_add_sharing_flags.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/emvi/logbuch"
|
||||||
|
"github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
f := migrationFunc{
|
||||||
|
name: "20210206_drop_badges_column_add_sharing_flags",
|
||||||
|
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||||
|
migrator := db.Migrator()
|
||||||
|
|
||||||
|
if !migrator.HasColumn(&models.User{}, "badges_enabled") {
|
||||||
|
// empty database, nothing to migrate
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Exec("UPDATE users SET share_data_max_days = 30 WHERE badges_enabled = TRUE").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.Exec("UPDATE users SET share_editors = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.Exec("UPDATE users SET share_languages = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.Exec("UPDATE users SET share_projects = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.Exec("UPDATE users SET share_oss = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.Exec("UPDATE users SET share_machines = TRUE WHERE badges_enabled = TRUE").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := migrator.DropColumn(&models.User{}, "badges_enabled"); err != nil {
|
||||||
|
return err
|
||||||
|
} else {
|
||||||
|
logbuch.Info("dropped column 'badges_enabled' after substituting it by sharing indicators")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
registerPostMigration(f)
|
||||||
|
}
|
@ -58,3 +58,7 @@ func (m *UserServiceMock) MigrateMd5Password(user *models.User, login *models.Lo
|
|||||||
args := m.Called(user, login)
|
args := m.Called(user, login)
|
||||||
return args.Get(0).(*models.User), args.Error(1)
|
return args.Get(0).(*models.User), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *UserServiceMock) FlushCache() {
|
||||||
|
m.Called()
|
||||||
|
}
|
||||||
|
@ -1,13 +1,18 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `json:"id" gorm:"primary_key"`
|
ID string `json:"id" gorm:"primary_key"`
|
||||||
ApiKey string `json:"api_key" gorm:"unique"`
|
ApiKey string `json:"api_key" gorm:"unique"`
|
||||||
Password string `json:"-"`
|
Password string `json:"-"`
|
||||||
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
|
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
|
||||||
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
|
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
|
||||||
BadgesEnabled bool `json:"-" gorm:"default:false; type:bool"`
|
ShareDataMaxDays uint `json:"-" gorm:"default:0"`
|
||||||
WakatimeApiKey string `json:"-"`
|
ShareEditors bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
|
ShareLanguages bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
|
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
|
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
|
||||||
|
ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
|
WakatimeApiKey string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Login struct {
|
type Login struct {
|
||||||
|
@ -54,7 +54,20 @@ func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *UserRepository) Update(user *models.User) (*models.User, error) {
|
func (r *UserRepository) Update(user *models.User) (*models.User, error) {
|
||||||
result := r.db.Model(user).Updates(user)
|
updateMap := map[string]interface{}{
|
||||||
|
"api_key": user.ApiKey,
|
||||||
|
"password": user.Password,
|
||||||
|
"last_logged_in_at": user.LastLoggedInAt,
|
||||||
|
"share_data_max_days": user.ShareDataMaxDays,
|
||||||
|
"share_editors": user.ShareEditors,
|
||||||
|
"share_languages": user.ShareLanguages,
|
||||||
|
"share_oss": user.ShareOSs,
|
||||||
|
"share_projects": user.ShareProjects,
|
||||||
|
"share_machines": user.ShareMachines,
|
||||||
|
"wakatime_api_key": user.WakatimeApiKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := r.db.Model(user).Updates(updateMap)
|
||||||
if err := result.Error; err != nil {
|
if err := result.Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -10,6 +10,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -46,13 +47,6 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
requestedUserId := mux.Vars(r)["user"]
|
|
||||||
user, err := h.userSrvc.GetUserById(requestedUserId)
|
|
||||||
if err != nil || !user.BadgesEnabled {
|
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var filterEntity, filterKey string
|
var filterEntity, filterKey string
|
||||||
if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 {
|
if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 {
|
||||||
filterEntity, filterKey = groups[1], groups[2]
|
filterEntity, filterKey = groups[1], groups[2]
|
||||||
@ -65,6 +59,21 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requestedUserId := mux.Vars(r)["user"]
|
||||||
|
user, err := h.userSrvc.GetUserById(requestedUserId)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, rangeFrom, rangeTo := utils.ResolveInterval(interval)
|
||||||
|
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays)))
|
||||||
|
if rangeFrom.Before(minStart) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte("requested time range too broad"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var filters *models.Filters
|
var filters *models.Filters
|
||||||
switch filterEntity {
|
switch filterEntity {
|
||||||
case "project":
|
case "project":
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/middlewares"
|
"github.com/muety/wakapi/middlewares"
|
||||||
@ -30,7 +29,7 @@ func NewStatsHandler(userService services.IUserService, summaryService services.
|
|||||||
func (h *StatsHandler) RegisterRoutes(router *mux.Router) {
|
func (h *StatsHandler) RegisterRoutes(router *mux.Router) {
|
||||||
r := router.PathPrefix("/wakatime/v1/users/{user}/stats/{range}").Subrouter()
|
r := router.PathPrefix("/wakatime/v1/users/{user}/stats/{range}").Subrouter()
|
||||||
r.Use(
|
r.Use(
|
||||||
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
|
middlewares.NewAuthenticateMiddleware(h.userSrvc).WithOptionalFor([]string{"/"}).Handler,
|
||||||
)
|
)
|
||||||
r.Methods(http.MethodGet).HandlerFunc(h.Get)
|
r.Methods(http.MethodGet).HandlerFunc(h.Get)
|
||||||
}
|
}
|
||||||
@ -38,42 +37,69 @@ func (h *StatsHandler) RegisterRoutes(router *mux.Router) {
|
|||||||
// TODO: support filtering (requires https://github.com/muety/wakapi/issues/108)
|
// TODO: support filtering (requires https://github.com/muety/wakapi/issues/108)
|
||||||
|
|
||||||
func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
vars := mux.Vars(r)
|
var vars = mux.Vars(r)
|
||||||
requestedUser := vars["user"]
|
var authorizedUser, requestedUser *models.User
|
||||||
requestedRange := vars["range"]
|
|
||||||
|
|
||||||
user := r.Context().Value(models.UserKey).(*models.User)
|
if u := r.Context().Value(models.UserKey); u != nil {
|
||||||
|
authorizedUser = u.(*models.User)
|
||||||
|
}
|
||||||
|
|
||||||
if requestedUser != user.ID && requestedUser != "current" {
|
if authorizedUser != nil && vars["user"] == "current" {
|
||||||
w.WriteHeader(http.StatusForbidden)
|
vars["user"] = authorizedUser.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
requestedUser, err := h.userSrvc.GetUserById(vars["user"])
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
w.Write([]byte("user not found"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
summary, err, status := h.loadUserSummary(user, requestedRange)
|
err, rangeFrom, rangeTo := utils.ResolveIntervalRaw(vars["range"])
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
w.Write([]byte("invalid range"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays)))
|
||||||
|
if (authorizedUser == nil || requestedUser.ID != authorizedUser.ID) &&
|
||||||
|
(requestedUser.ShareDataMaxDays == 0 || rangeFrom.Before(minStart)) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte("requested time range too broad"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err, status := h.loadUserSummary(requestedUser, rangeFrom, rangeTo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
w.Write([]byte(err.Error()))
|
w.Write([]byte(err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filters := &models.Filters{}
|
stats := v1.NewStatsFrom(summary, &models.Filters{})
|
||||||
if projectQuery := r.URL.Query().Get("project"); projectQuery != "" {
|
|
||||||
filters.Project = projectQuery
|
// post filter stats according to user's given sharing permissions
|
||||||
|
if !requestedUser.ShareEditors {
|
||||||
|
stats.Data.Editors = nil
|
||||||
|
}
|
||||||
|
if !requestedUser.ShareLanguages {
|
||||||
|
stats.Data.Languages = nil
|
||||||
|
}
|
||||||
|
if !requestedUser.ShareProjects {
|
||||||
|
stats.Data.Projects = nil
|
||||||
|
}
|
||||||
|
if !requestedUser.ShareOSs {
|
||||||
|
stats.Data.OperatingSystems = nil
|
||||||
|
}
|
||||||
|
if !requestedUser.ShareMachines {
|
||||||
|
stats.Data.Machines = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
vm := v1.NewStatsFrom(summary, filters)
|
utils.RespondJSON(w, http.StatusOK, stats)
|
||||||
utils.RespondJSON(w, http.StatusOK, vm)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *StatsHandler) loadUserSummary(user *models.User, rangeKey string) (*models.Summary, error, int) {
|
func (h *StatsHandler) loadUserSummary(user *models.User, start, end time.Time) (*models.Summary, error, int) {
|
||||||
var start, end time.Time
|
|
||||||
|
|
||||||
if err, parsedFrom, parsedTo := utils.ResolveIntervalRaw(rangeKey); err == nil {
|
|
||||||
start, end = parsedFrom, parsedTo
|
|
||||||
} else {
|
|
||||||
return nil, errors.New("invalid 'range' parameter"), http.StatusBadRequest
|
|
||||||
}
|
|
||||||
|
|
||||||
overallParams := &models.SummaryParams{
|
overallParams := &models.SummaryParams{
|
||||||
From: start,
|
From: start,
|
||||||
To: end,
|
To: end,
|
||||||
|
@ -2,6 +2,7 @@ package routes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
@ -127,8 +128,8 @@ func (h *SettingsHandler) dispatchAction(action string) action {
|
|||||||
return h.actionDeleteLanguageMapping
|
return h.actionDeleteLanguageMapping
|
||||||
case "add_mapping":
|
case "add_mapping":
|
||||||
return h.actionAddLanguageMapping
|
return h.actionAddLanguageMapping
|
||||||
case "toggle_badges":
|
case "update_sharing":
|
||||||
return h.actionToggleBadges
|
return h.actionUpdateSharing
|
||||||
case "toggle_wakatime":
|
case "toggle_wakatime":
|
||||||
return h.actionSetWakatimeApiKey
|
return h.actionSetWakatimeApiKey
|
||||||
case "import_wakatime":
|
case "import_wakatime":
|
||||||
@ -202,6 +203,38 @@ func (h *SettingsHandler) actionResetApiKey(w http.ResponseWriter, r *http.Reque
|
|||||||
return http.StatusOK, msg, ""
|
return http.StatusOK, msg, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *SettingsHandler) actionUpdateSharing(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||||
|
if h.config.IsDev() {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
user := r.Context().Value(models.UserKey).(*models.User)
|
||||||
|
|
||||||
|
defer h.userSrvc.FlushCache()
|
||||||
|
|
||||||
|
user.ShareProjects, err = strconv.ParseBool(r.PostFormValue("share_projects"))
|
||||||
|
user.ShareLanguages, err = strconv.ParseBool(r.PostFormValue("share_languages"))
|
||||||
|
user.ShareEditors, err = strconv.ParseBool(r.PostFormValue("share_editors"))
|
||||||
|
user.ShareOSs, err = strconv.ParseBool(r.PostFormValue("share_oss"))
|
||||||
|
user.ShareMachines, err = strconv.ParseBool(r.PostFormValue("share_machines"))
|
||||||
|
if v, e := strconv.Atoi(r.PostFormValue("max_days")); e == nil && v >= 0 {
|
||||||
|
user.ShareDataMaxDays = uint(v)
|
||||||
|
} else {
|
||||||
|
err = errors.New("")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusBadRequest, "", "invalid input"
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := h.userSrvc.Update(user); err != nil {
|
||||||
|
return http.StatusInternalServerError, "", "internal sever error"
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.StatusOK, "settings updated", ""
|
||||||
|
}
|
||||||
|
|
||||||
func (h *SettingsHandler) actionDeleteAlias(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
func (h *SettingsHandler) actionDeleteAlias(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||||
if h.config.IsDev() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
@ -299,19 +332,6 @@ func (h *SettingsHandler) actionAddLanguageMapping(w http.ResponseWriter, r *htt
|
|||||||
return http.StatusOK, "mapping added successfully", ""
|
return http.StatusOK, "mapping added successfully", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SettingsHandler) actionToggleBadges(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
|
||||||
if h.config.IsDev() {
|
|
||||||
loadTemplates()
|
|
||||||
}
|
|
||||||
|
|
||||||
user := r.Context().Value(models.UserKey).(*models.User)
|
|
||||||
if _, err := h.userSrvc.ToggleBadges(user); err != nil {
|
|
||||||
return http.StatusInternalServerError, "", "internal server error"
|
|
||||||
}
|
|
||||||
|
|
||||||
return http.StatusOK, "", ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *SettingsHandler) actionSetWakatimeApiKey(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
func (h *SettingsHandler) actionSetWakatimeApiKey(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||||
if h.config.IsDev() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
|
@ -67,7 +67,7 @@ type IUserService interface {
|
|||||||
Update(*models.User) (*models.User, error)
|
Update(*models.User) (*models.User, error)
|
||||||
Delete(*models.User) error
|
Delete(*models.User) error
|
||||||
ResetApiKey(*models.User) (*models.User, error)
|
ResetApiKey(*models.User) (*models.User, error)
|
||||||
ToggleBadges(*models.User) (*models.User, error)
|
|
||||||
SetWakatimeApiKey(*models.User, string) (*models.User, error)
|
SetWakatimeApiKey(*models.User, string) (*models.User, error)
|
||||||
MigrateMd5Password(*models.User, *models.Login) (*models.User, error)
|
MigrateMd5Password(*models.User, *models.Login) (*models.User, error)
|
||||||
|
FlushCache()
|
||||||
}
|
}
|
||||||
|
@ -83,11 +83,6 @@ func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
|
|||||||
return srv.Update(user)
|
return srv.Update(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *UserService) ToggleBadges(user *models.User) (*models.User, error) {
|
|
||||||
srv.cache.Flush()
|
|
||||||
return srv.repository.UpdateField(user, "badges_enabled", !user.BadgesEnabled)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (srv *UserService) SetWakatimeApiKey(user *models.User, apiKey string) (*models.User, error) {
|
func (srv *UserService) SetWakatimeApiKey(user *models.User, apiKey string) (*models.User, error) {
|
||||||
srv.cache.Flush()
|
srv.cache.Flush()
|
||||||
return srv.repository.UpdateField(user, "wakatime_api_key", apiKey)
|
return srv.repository.UpdateField(user, "wakatime_api_key", apiKey)
|
||||||
@ -108,3 +103,7 @@ func (srv *UserService) Delete(user *models.User) error {
|
|||||||
srv.cache.Flush()
|
srv.cache.Flush()
|
||||||
return srv.repository.Delete(user)
|
return srv.repository.Delete(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (srv *UserService) FlushCache() {
|
||||||
|
srv.cache.Flush()
|
||||||
|
}
|
||||||
|
@ -33,237 +33,355 @@
|
|||||||
|
|
||||||
<main class="mt-4 flex-grow flex justify-center w-full">
|
<main class="mt-4 flex-grow flex justify-center w-full">
|
||||||
<div class="flex flex-col flex-grow max-w-xl mt-8">
|
<div class="flex flex-col flex-grow max-w-xl mt-8">
|
||||||
<div class="text-gray-500 text-xs mb-8">
|
|
||||||
<ul class="flex justify-center flex-wrap space-x-1 inline-bullet-list px-12">
|
|
||||||
<li class="hover:text-gray-400 mb-1">
|
|
||||||
<a href="settings#password">Change Password</a>
|
|
||||||
</li>
|
|
||||||
<li class="hover:text-gray-400 mb-1">
|
|
||||||
<a href="settings#apikey">Reset API Key</a>
|
|
||||||
</li>
|
|
||||||
<li class="hover:text-gray-400 mb-1">
|
|
||||||
<a href="settings#aliases">Aliases</a>
|
|
||||||
</li>
|
|
||||||
<li class="hover:text-gray-400 mb-1">
|
|
||||||
<a href="settings#languages">Languages & File Extensions</a>
|
|
||||||
</li>
|
|
||||||
<li class="hover:text-gray-400 mb-1">
|
|
||||||
<a href="settings#badges">Badges</a>
|
|
||||||
</li>
|
|
||||||
<li class="hover:text-gray-400 mb-1">
|
|
||||||
<a href="settings#integrations">Integrations</a>
|
|
||||||
</li>
|
|
||||||
<li class="hover:text-gray-400 mb-1">
|
|
||||||
<a href="settings#danger">Danger Zone</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full my-8 pb-8 border-b border-gray-700">
|
<details class="my-8 pb-8 border-b border-gray-700">
|
||||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="password">
|
<summary class="cursor-pointer">
|
||||||
Change Password
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="password">
|
||||||
</h2>
|
Change Password
|
||||||
|
</h2>
|
||||||
<form class="mt-10" action="" method="post">
|
</summary>
|
||||||
<input type="hidden" name="action" value="change_password">
|
<div class="w-full">
|
||||||
<div class="mb-8">
|
<form class="mt-10" action="" method="post">
|
||||||
<label class="inline-block text-sm mb-1 text-gray-500" for="password_old">Current Password</label>
|
<input type="hidden" name="action" value="change_password">
|
||||||
<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"
|
<div class="mb-8">
|
||||||
type="password" id="password_old"
|
<label class="inline-block text-sm mb-1 text-gray-500" for="password_old">Current Password</label>
|
||||||
name="password_old" placeholder="Enter your old password" minlength="6" required>
|
<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"
|
||||||
</div>
|
type="password" id="password_old"
|
||||||
<div class="mb-8">
|
name="password_old" placeholder="Enter your old password" minlength="6" required>
|
||||||
<label class="inline-block text-sm mb-1 text-gray-500" for="password_new">New Password</label>
|
</div>
|
||||||
<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"
|
<div class="mb-8">
|
||||||
type="password" id="password_new"
|
<label class="inline-block text-sm mb-1 text-gray-500" for="password_new">New Password</label>
|
||||||
name="password_new" placeholder="Choose a password" minlength="6" required>
|
<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"
|
||||||
</div>
|
type="password" id="password_new"
|
||||||
<div class="mb-8">
|
name="password_new" placeholder="Choose a password" minlength="6" required>
|
||||||
<label class="inline-block text-sm mb-1 text-gray-500" for="password_repeat">And again ...</label>
|
</div>
|
||||||
<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"
|
<div class="mb-8">
|
||||||
type="password" id="password_repeat"
|
<label class="inline-block text-sm mb-1 text-gray-500" for="password_repeat">And again ...</label>
|
||||||
name="password_repeat" placeholder="Repeat your password" minlength="6" required>
|
<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"
|
||||||
</div>
|
type="password" id="password_repeat"
|
||||||
<div class="flex justify-between float-right">
|
name="password_repeat" placeholder="Repeat your password" minlength="6" required>
|
||||||
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
</div>
|
||||||
Save
|
<div class="flex justify-between float-right">
|
||||||
</button>
|
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
||||||
</div>
|
Save
|
||||||
</form>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700">
|
|
||||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="apikey">
|
|
||||||
Reset API Key
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<form class="mt-6" action="" method="post">
|
|
||||||
<input type="hidden" name="action" value="reset_apikey">
|
|
||||||
<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 class="w-full mt-4 mb-8 pb-8 border-b border-gray-700" id="aliases">
|
|
||||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
|
||||||
Aliases
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="text-gray-300 text-sm mb-4 mt-6">
|
|
||||||
You can specify aliases for any type of entity. For instance, you can define a rule, that both <span
|
|
||||||
class="inline-block mb-1 text-gray-500 italic">myapp-frontend</span> and <span
|
|
||||||
class="inline-block mb-1 text-gray-500 italic">myapp-backend</span> are combined under a
|
|
||||||
project called <span class="inline-block mb-1 text-gray-500 italic">myapp</span>.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{ if .Aliases }}
|
|
||||||
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white">Rules</h3>
|
|
||||||
{{ range $i, $alias := .Aliases }}
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="text-gray-500 border-1 w-full border-green-700 inline-block my-1 py-1 text-align text-sm"
|
|
||||||
style="line-height: 1.8">
|
|
||||||
▸ All <span class="underline">{{ $alias.Type | typeName }}s</span> named
|
|
||||||
{{ range $j, $value := $alias.Values }}
|
|
||||||
<span class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">{{- $value -}}</span>
|
|
||||||
{{ if lt $j (add (len $alias.Values) -2) }}
|
|
||||||
<span class="-ml-1">{{- ", " | capitalize -}}</span>
|
|
||||||
{{ else if lt $j (add (len $alias.Values) -1) }}
|
|
||||||
<span>{{- "or" -}}</span>
|
|
||||||
{{ end }}
|
|
||||||
{{ end }}
|
|
||||||
are mapped to <span class="underline">{{ $alias.Type | typeName }}</span> <span
|
|
||||||
class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">{{ $alias.Key }}</span>.
|
|
||||||
</div>
|
|
||||||
<form class="float-right" action="" method="post">
|
|
||||||
<input type="hidden" name="action" value="delete_alias">
|
|
||||||
<input type="hidden" id="delete_alias_key" name="key" required value="{{ $alias.Key }}">
|
|
||||||
<input type="hidden" id="delete_alias_type" name="type" required value="{{ $alias.Type }}">
|
|
||||||
<button type="submit"
|
|
||||||
class="py-1 px-3 rounded border border-red-500 hover:border-red-600 text-gray-400 text-sm">
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
</details>
|
||||||
<div class="mb-8"></div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white mb-2">Add Rule</h3>
|
<details class="mb-8 pb-8 border-b border-gray-700">
|
||||||
<form action="" method="post">
|
<summary class="cursor-pointer">
|
||||||
<input type="hidden" name="action" value="add_alias">
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||||
<div class="flex items-center mt-2 w-full text-gray-500 text-sm">
|
Aliases
|
||||||
<span class="mr-2">Map</span>
|
</h2>
|
||||||
<select name="type" id="select-type"
|
</summary>
|
||||||
class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3 cursor-pointer">
|
<div class="w-full" id="aliases">
|
||||||
{{ range $i, $t := entityTypes }}
|
<div class="text-gray-300 text-sm mb-4 mt-6">
|
||||||
<option value="{{ $t }}">{{ $t | typeName | capitalize }}</option>
|
You can specify aliases for any type of entity. For instance, you can define a rule, that both <span
|
||||||
|
class="inline-block mb-1 text-gray-500 italic">myapp-frontend</span> and <span
|
||||||
|
class="inline-block mb-1 text-gray-500 italic">myapp-backend</span> are combined under a
|
||||||
|
project called <span class="inline-block mb-1 text-gray-500 italic">myapp</span>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ if .Aliases }}
|
||||||
|
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white">Rules</h3>
|
||||||
|
{{ range $i, $alias := .Aliases }}
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="text-gray-500 border-1 w-full border-green-700 inline-block my-1 py-1 text-align text-sm"
|
||||||
|
style="line-height: 1.8">
|
||||||
|
▸ All <span class="underline">{{ $alias.Type | typeName }}s</span> named
|
||||||
|
{{ range $j, $value := $alias.Values }}
|
||||||
|
<span class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">{{- $value -}}</span>
|
||||||
|
{{ if lt $j (add (len $alias.Values) -2) }}
|
||||||
|
<span class="-ml-1">{{- ", " | capitalize -}}</span>
|
||||||
|
{{ else if lt $j (add (len $alias.Values) -1) }}
|
||||||
|
<span>{{- "or" -}}</span>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</select>
|
{{ end }}
|
||||||
<span class="mx-2">named</span>
|
are mapped to <span class="underline">{{ $alias.Type | typeName }}</span> <span
|
||||||
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
|
class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">{{ $alias.Key }}</span>.
|
||||||
type="text" id="alias-value" style="width: 130px;"
|
|
||||||
name="value" placeholder="myapp-frontend" minlength="1" required>
|
|
||||||
<span class="mx-2">to</span>
|
|
||||||
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
|
|
||||||
type="text" id="alias-key" style="width: 100px"
|
|
||||||
name="key" placeholder="myapp" minlength="1" required>
|
|
||||||
<div class="flex-grow flex justify-end">
|
|
||||||
<button type="submit"
|
|
||||||
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<form class="float-right" action="" method="post">
|
||||||
|
<input type="hidden" name="action" value="delete_alias">
|
||||||
|
<input type="hidden" id="delete_alias_key" name="key" required value="{{ $alias.Key }}">
|
||||||
|
<input type="hidden" id="delete_alias_type" name="type" required value="{{ $alias.Type }}">
|
||||||
|
<button type="submit"
|
||||||
|
class="py-1 px-3 rounded border border-red-500 hover:border-red-600 text-gray-400 text-sm">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
{{end}}
|
||||||
</div>
|
<div class="mb-8"></div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700">
|
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white mb-2">Add Rule</h3>
|
||||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="languages">
|
<form action="" method="post">
|
||||||
Languages & File Extensions
|
<input type="hidden" name="action" value="add_alias">
|
||||||
</div>
|
<div class="flex items-center mt-2 w-full text-gray-500 text-sm">
|
||||||
|
<span class="mr-2">Map</span>
|
||||||
<div class="text-gray-300 text-sm mb-4 mt-6">
|
<select name="type" id="select-type"
|
||||||
You can specify custom mapping from file extensions to programming languages, for instance a <span
|
class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3 cursor-pointer">
|
||||||
class="inline-block mb-1 text-gray-500 italic">.jsx</span> file could be mapped to the <span
|
{{ range $i, $t := entityTypes }}
|
||||||
class="inline-block mb-1 text-gray-500 italic">React</span> language.
|
<option value="{{ $t }}">{{ $t | typeName | capitalize }}</option>
|
||||||
</div>
|
{{ end }}
|
||||||
|
</select>
|
||||||
{{ if .LanguageMappings }}
|
<span class="mx-2">named</span>
|
||||||
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white">Rules</h3>
|
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
|
||||||
{{ range $i, $mapping := .LanguageMappings }}
|
type="text" id="alias-value" style="width: 130px;"
|
||||||
<div class="flex items-center">
|
name="value" placeholder="myapp-frontend" minlength="1" required>
|
||||||
<div class="text-gray-500 border-1 w-full border-green-700 inline-block my-1 py-1 text-align text-sm">
|
<span class="mx-2">to</span>
|
||||||
▸ When filename ends in <span
|
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
|
||||||
class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono mr-1">{{ $mapping.Extension }}</span>
|
type="text" id="alias-key" style="width: 100px"
|
||||||
then change the <span class="underline">language</span> to <span
|
name="key" placeholder="myapp" minlength="1" required>
|
||||||
class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono mr-1">{{ $mapping.Language }}</span>
|
<div class="flex-grow flex justify-end">
|
||||||
</div>
|
<button type="submit"
|
||||||
<form class="float-right" action="" method="post">
|
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
||||||
<input type="hidden" name="action" value="delete_mapping">
|
Add
|
||||||
<input type="hidden" id="mapping_id" name="mapping_id" required value="{{ $mapping.ID }}">
|
</button>
|
||||||
<button type="submit"
|
</div>
|
||||||
class="py-1 px-3 rounded border border-red-500 hover:border-red-600 text-gray-400 text-sm">
|
</div>
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
</details>
|
||||||
<div class="mb-8"></div>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white">Add Rule</h3>
|
<details class="mb-8 pb-8 border-b border-gray-700">
|
||||||
<form action="" method="post">
|
<summary class="cursor-pointer">
|
||||||
<input type="hidden" name="action" value="add_mapping">
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
||||||
<div class="flex items-center w-full text-gray-500 text-sm">
|
id="languages">
|
||||||
<span class="mr-2">When filename ends in</span>
|
Custom Mappings
|
||||||
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
|
</h2>
|
||||||
type="text" id="extension" style="width: 70px"
|
</summary>
|
||||||
name="extension" placeholder=".py" minlength="1" required>
|
<div class="w-full">
|
||||||
<span class="mx-2">change language to</span>
|
<div class="text-gray-300 text-sm mb-4 mt-6">
|
||||||
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
|
You can specify custom mapping from file extensions to programming languages, for instance a <span
|
||||||
type="text" id="language" style="width: 100px"
|
class="inline-block mb-1 text-gray-500 italic">.jsx</span> file could be mapped to the <span
|
||||||
name="language" placeholder="Python" minlength="1" required>
|
class="inline-block mb-1 text-gray-500 italic">React</span> language.
|
||||||
<div class="flex-grow flex justify-end">
|
|
||||||
<button type="submit"
|
|
||||||
class="py-1 px-3 my-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700">
|
{{ if .LanguageMappings }}
|
||||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="badges">
|
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white">Rules</h3>
|
||||||
Badges
|
{{ range $i, $mapping := .LanguageMappings }}
|
||||||
</div>
|
<div class="flex items-center">
|
||||||
|
<div class="text-gray-500 border-1 w-full border-green-700 inline-block my-1 py-1 text-align text-sm">
|
||||||
<form class="mt-6" action="" method="post">
|
▸ When filename ends in <span
|
||||||
<input type="hidden" name="action" value="toggle_badges">
|
class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono mr-1">{{ $mapping.Extension }}</span>
|
||||||
<div class="text-gray-300 text-sm mb-4">
|
then change the <span class="underline">language</span> to <span
|
||||||
{{ if .User.BadgesEnabled }}
|
class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono mr-1">{{ $mapping.Language }}</span>
|
||||||
<p>Badges are currently enabled. You can disable the feature by deactivating the respective API
|
</div>
|
||||||
endpoint.</p>
|
<form class="float-right" action="" method="post">
|
||||||
|
<input type="hidden" name="action" value="delete_mapping">
|
||||||
<div class="flex justify-around mt-4">
|
<input type="hidden" id="mapping_id" name="mapping_id" required value="{{ $mapping.ID }}">
|
||||||
<span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">GET /api/compat/shields/v1</span>
|
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="py-1 px-2 rounded bg-orange-700 hover:bg-orange-800 text-white text-xs"
|
class="py-1 px-3 rounded border border-red-500 hover:border-red-600 text-gray-400 text-sm">
|
||||||
title="Disable support for badges to secure endpoint">
|
✕
|
||||||
Status: public
|
|
||||||
</button>
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div class="mb-8"></div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white">Add Rule</h3>
|
||||||
|
<form action="" method="post">
|
||||||
|
<input type="hidden" name="action" value="add_mapping">
|
||||||
|
<div class="flex items-center w-full text-gray-500 text-sm">
|
||||||
|
<span class="mr-2">When filename ends in</span>
|
||||||
|
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
|
||||||
|
type="text" id="extension" style="width: 70px"
|
||||||
|
name="extension" placeholder=".py" minlength="1" required>
|
||||||
|
<span class="mx-2">change language to</span>
|
||||||
|
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
|
||||||
|
type="text" id="language" style="width: 100px"
|
||||||
|
name="language" placeholder="Python" minlength="1" required>
|
||||||
|
<div class="flex-grow flex justify-end">
|
||||||
|
<button type="submit"
|
||||||
|
class="py-1 px-3 my-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="mb-8 pb-8 border-b border-gray-700" id="public_data">
|
||||||
|
<summary class="cursor-pointer">
|
||||||
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||||
|
Public Data
|
||||||
|
</h2>
|
||||||
|
</summary>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-300 text-sm mb-4 mt-6">Some features require public access to your data without authentication. This mainly includes <strong>Badges</strong> and the integration with <strong>GitHub Readme Stats</strong>, corresponding to these API endpoints:</p>
|
||||||
|
<ul class="list-disc list-inside text-gray-300">
|
||||||
|
<li class="ml-2"><span class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">/api/compat/shields/v1/{user}</span></li>
|
||||||
|
<li class="ml-2"><span class="text-white text-xs bg-gray-900 rounded py-1 px-2 font-mono">/api/v1/users/{user}/stats/{range}</span></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form action="" method="post" class="mt-8">
|
||||||
|
<input type="hidden" name="action" value="update_sharing">
|
||||||
|
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
|
||||||
|
<span class="mr-2">Publicly accessible data range:<br><span class="text-xs text-gray-500">(in days; 0 = not public)</span></span>
|
||||||
|
<div>
|
||||||
|
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
|
||||||
|
style="width: 70px;" type="number" id="max_days" name="max_days" min="0" max="365" required value="{{ .User.ShareDataMaxDays }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
|
||||||
|
<div class="flex justify-start">
|
||||||
|
<span class="mr-2">Share projects: </span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<select autocomplete="off" name="share_projects" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||||
|
<option value="false" class="cursor-pointer" {{ if not .User.ShareProjects }} selected {{ end }}>No</option>
|
||||||
|
<option value="true" class="cursor-pointer" {{ if .User.ShareProjects }} selected {{ end }}>Yes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
|
||||||
|
<div class="flex justify-start">
|
||||||
|
<span class="mr-2">Share languages: </span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<select autocomplete="off" name="share_languages" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||||
|
<option value="false" class="cursor-pointer" {{ if not .User.ShareLanguages }} selected {{ end }}>No</option>
|
||||||
|
<option value="true" class="cursor-pointer" {{ if .User.ShareLanguages }} selected {{ end }}>Yes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
|
||||||
|
<div class="flex justify-start">
|
||||||
|
<span class="mr-2">Share editors: </span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<select autocomplete="off" name="share_editors" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||||
|
<option value="false" class="cursor-pointer" {{ if not .User.ShareEditors }} selected {{ end }}>No</option>
|
||||||
|
<option value="true" class="cursor-pointer" {{ if .User.ShareEditors }} selected {{ end }}>Yes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
|
||||||
|
<div class="flex justify-start">
|
||||||
|
<span class="mr-2">Share operating systems: </span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<select autocomplete="off" name="share_oss" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||||
|
<option value="false" class="cursor-pointer" {{ if not .User.ShareOSs }} selected {{ end }}>No</option>
|
||||||
|
<option value="true" class="cursor-pointer" {{ if .User.ShareOSs }} selected {{ end }}>Yes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
|
||||||
|
<div class="flex justify-start">
|
||||||
|
<span class="mr-2">Share machines: </span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<select autocomplete="off" name="share_machines" class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||||
|
<option value="false" class="cursor-pointer" {{ if not .User.ShareMachines }} selected {{ end }}>No</option>
|
||||||
|
<option value="true" class="cursor-pointer" {{ if .User.ShareMachines }} selected {{ end }}>Yes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="font-semibold mb-2 mt-8">Examples</h3>
|
<div class="flex justify-between float-right mt-4">
|
||||||
|
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm" style="width: 100px;">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="mb-8 pb-8 border-b border-gray-700">
|
||||||
|
<summary class="cursor-pointer">
|
||||||
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
||||||
|
id="integrations">
|
||||||
|
Integrations
|
||||||
|
</h2>
|
||||||
|
</summary>
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="mt-8 text-gray-300 text-sm">
|
||||||
|
<h3 class="inline-block font-semibold text-md mb-4 border-b border-green-700">
|
||||||
|
WakaTime
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
<img alt="WakaTime Logo"
|
||||||
|
width="55px"
|
||||||
|
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzQwIiBoZWlnaHQ9IjM0MCIgdmlld0JveD0iMCAwIDM0MCAzNDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTcwIDIwQzg3LjE1NiAyMCAyMCA4Ny4xNTYgMjAgMTcwQzIwIDI1Mi44NDQgODcuMTU2IDMyMCAxNzAgMzIwQzI1Mi44NDQgMzIwIDMyMCAyNTIuODQ0IDMyMCAxNzBDMzIwIDg3LjE1NiAyNTIuODQ0IDIwIDE3MCAyMFYyMFYyMFoiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS13aWR0aD0iNDAiLz4KPHBhdGggZD0iTTE5MC4xODMgMjEzLjU0MUMxODguNzQgMjE1LjQ0MyAxODYuNTc2IDIxNi42NjcgMTg0LjE1MSAyMTYuNjY3QzE4My45MTMgMjE2LjY2NyAxODMuNjc3IDIxNi42NTEgMTgzLjQ0MyAyMTYuNjI3QzE4My4wNDIgMjE2LjU3OSAxODIuODIzIDIxNi41NDUgMTgyLjYwNiAyMTYuNDk3QzE4Mi4zMzcgMjE2LjQzNCAxODIuMTM3IDIxNi4zNzUgMTgxLjk0IDIxNi4zMDhDMTgxLjU2MSAyMTYuMTc2IDE4MS4zOTIgMjE2LjEwOSAxODEuMjI4IDIxNi4wMzVDMTgwLjg0MyAyMTUuODQ5IDE4MC43MDcgMjE1Ljc3OCAxODAuNTcyIDIxNS43MDFDMTgwLjIwNSAyMTUuNDc4IDE4MC4xMDkgMjE1LjQxMiAxODAuMDE0IDIxNS4zNDVDMTc5Ljg1NiAyMTUuMjMzIDE3OS42OTggMjE1LjExNyAxNzkuNTQ3IDIxNC45OTJDMTc5LjI1MSAyMTQuNzQ2IDE3OS4xNDcgMjE0LjY1IDE3OS4wNDQgMjE0LjU1MkMxNzguNzMxIDIxNC4yNDEgMTc4LjUzMSAyMTQuMDE4IDE3OC4zNDEgMjEzLjc4NUMxNzcuOTgyIDIxMy4zMzEgMTc3LjY5IDIxMi44ODggMTc3LjQzOCAyMTIuNDE1TDE2OC42IDE5OC4yMTRMMTU5Ljc2NiAyMTIuNDE1QzE1OC4zOCAyMTQuOTM5IDE1NS44NzQgMjE2LjY2NyAxNTIuOTk1IDIxNi42NjdDMTUwLjEwNiAyMTYuNjY3IDE0Ny41ODggMjE0LjkyNiAxNDYuMjQzIDIxMi4zNDZMMTA3LjYwNyAxNTYuMDYxQzEwNi4zMzcgMTU0LjUyOSAxMDUuNTU2IDE1Mi40OTkgMTA1LjU1NiAxNTAuMjU4QzEwNS41NTYgMTQ1LjUxNCAxMDkuMDQzIDE0MS42NjUgMTEzLjM0NCAxNDEuNjY1QzExNi4xMjcgMTQxLjY2NSAxMTguNTY0IDE0My4yODIgMTE5Ljk0MiAxNDUuNzA4TDE1Mi41NTUgMTkzLjlMMTYxLjczNSAxNzguOTUyQzE2My4wNTggMTc2LjI4OCAxNjUuNjI2IDE3NC40NzggMTY4LjU3NSAxNzQuNDc4QzE3MS4yNzMgMTc0LjQ3OCAxNzMuNjUyIDE3NS45OTYgMTc1LjA0OSAxNzguMjk4TDE4NC41MTcgMTkzLjgzOUwyMzUuNjg0IDEyMC41ODNDMjM3LjA3NSAxMTguMjI2IDIzOS40NzUgMTE2LjY2NyAyNDIuMjEzIDExNi42NjdDMjQ2LjUxNCAxMTYuNjY3IDI1MCAxMjAuNTE0IDI1MCAxMjUuMjU4QzI1MCAxMjcuMzMyIDI0OS4zMzcgMTI5LjIzMiAyNDguMjMgMTMwLjcxNUwxOTAuMTgzIDIxMy41NDFWMjEzLjU0MVoiIGZpbGw9IndoaXRlIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjEwIi8+Cjwvc3ZnPgo=">
|
||||||
|
<p class="text-sm">You can connect Wakapi with the official <a class="underline"
|
||||||
|
href="https://wakatime.com"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank">WakaTime</a> in a
|
||||||
|
way
|
||||||
|
that all heartbeats sent to Wakapi are relayed. This way, you can use both services
|
||||||
|
at
|
||||||
|
the same time. To get started, <a class="underline"
|
||||||
|
href="https://wakatime.com/developers#authentication"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank">get your API key</a> and paste it here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="" method="post">
|
||||||
|
<input type="hidden" name="action" value="toggle_wakatime">
|
||||||
|
|
||||||
|
{{ $placeholderText := "Paste your WakaTime API key here ..." }}
|
||||||
|
{{ if .User.WakatimeApiKey }}
|
||||||
|
{{ $placeholderText = "********" }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
<div class="flex items-center mt-8 space-x-2">
|
||||||
|
<label class="text-gray-500 font-semibold">API Key:</label>
|
||||||
|
<input type="password" name="api_key" id="wakatime_api_key"
|
||||||
|
class="flex-grow shadow appearance-nonshadow appearance-none bg-gray-800 text-gray-300 border-green-700 border rounded py-1 px-3 {{ if not .User.WakatimeApiKey }}focus:bg-gray-700 focus:border-gray-500{{ end }}"
|
||||||
|
placeholder="{{ $placeholderText }}" {{ if .User.WakatimeApiKey }}readonly{{ end }}>
|
||||||
|
<div class="flex-grow flex justify-end">
|
||||||
|
{{ if not .User.WakatimeApiKey }}
|
||||||
|
<button type="submit"
|
||||||
|
class="py-1 px-3 my-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
{{ else }}
|
||||||
|
<button type="submit"
|
||||||
|
class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm"
|
||||||
|
style="width: 130px">Disconnect
|
||||||
|
</button>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ if .User.WakatimeApiKey }}
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button id="btn-import-wakatime" type="button" style="width: 130px"
|
||||||
|
class="py-1 px-3 my-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
||||||
|
⤵ Import Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form action="" method="post" id="form-import-wakatime" class="mt-6">
|
||||||
|
<input type="hidden" name="action" value="import_wakatime">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-6">
|
||||||
|
<span class="font-semibold">👉 Please note:</span>
|
||||||
|
<span>When enabling this feature, the operators of this server will, in theory (!), have unlimited access to your data stored in WakaTime. If you are concerned about your privacy, please do not enable this integration or wait for OAuth 2 authentication (<a
|
||||||
|
class="underline" target="_blank" href="https://github.com/muety/wakapi/issues/94"
|
||||||
|
rel="noopener noreferrer">#94</a>) to be implemented.</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 text-gray-300 text-sm">
|
||||||
|
<h3 class="inline-block font-semibold text-md mb-4 border-b border-green-700">
|
||||||
|
Badges (Shields.io)
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{{ if gt .User.ShareDataMaxDays 0 }}
|
||||||
|
<h3 class="font-semibold mb-2">Examples</h3>
|
||||||
<div class="flex flex-col mb-4">
|
<div class="flex flex-col mb-4">
|
||||||
<div class="flex justify-between my-2">
|
<div class="flex justify-between my-2">
|
||||||
<div>
|
<div>
|
||||||
@ -295,152 +413,97 @@
|
|||||||
<p>You have the ability to create badges from your coding statistics using <a
|
<p>You have the ability to create badges from your coding statistics using <a
|
||||||
href="https://shields.io" target="_blank" class="border-b border-green-800"
|
href="https://shields.io" target="_blank" class="border-b border-green-800"
|
||||||
rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized
|
rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized
|
||||||
access to the respective endpoint.</p>
|
access to the respective endpoint. See <a href="settings#public_data" class="underline">Public Data</a> setting.</p>
|
||||||
<div class="flex justify-around mt-4">
|
{{ end }}
|
||||||
<span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">GET /api/compat/shields/v1</span>
|
</div>
|
||||||
<button type="submit"
|
</div>
|
||||||
class="py-1 px-2 rounded bg-green-700 hover:bg-green-800 text-white text-xs"
|
</details>
|
||||||
title="Make endpoint public to enable badges">
|
|
||||||
Status: protected
|
<details class="mb-8 pb-8">
|
||||||
|
<summary class="cursor-pointer">
|
||||||
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="danger">
|
||||||
|
⚠️ Danger Zone
|
||||||
|
</h2>
|
||||||
|
</summary>
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="mt-10 text-gray-300 text-sm">
|
||||||
|
<h3 class="font-semibold text-md mb-4 border-b border-green-700 inline-block">
|
||||||
|
Regenerate summaries
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
Wakapi improves its efficiency and speed by automatically aggregating individual heartbeats to
|
||||||
|
summaries on a per-day basis.
|
||||||
|
That is, historic summaries, i.e. such from past days, are generated once and only fetched from
|
||||||
|
the
|
||||||
|
database in a static fashion afterwards, unless you pass <span
|
||||||
|
class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">&recompute=true</span>
|
||||||
|
with your request.
|
||||||
|
</p>
|
||||||
|
<p class="mt-2">
|
||||||
|
If, for some reason, these aggregated summaries are faulty or preconditions have change (e.g.
|
||||||
|
you
|
||||||
|
modified language mappings retrospectively), you may want to re-generate them from raw
|
||||||
|
heartbeats.
|
||||||
|
</p>
|
||||||
|
<p class="mt-2">
|
||||||
|
<strong>Note:</strong> Only run this action if you know what you are doing. Data might be lost
|
||||||
|
is
|
||||||
|
case heartbeats were deleted after the respective summaries had been generated.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex justify-center">
|
||||||
|
<form action="" method="post" id="form-regenerate-summaries">
|
||||||
|
<input type="hidden" name="action" value="regenerate_summaries">
|
||||||
|
<button type="button" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm"
|
||||||
|
id="btn-regenerate-summaries">
|
||||||
|
Clear & Regenerate
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700">
|
|
||||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="integrations">
|
|
||||||
Integrations
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="mt-10 text-gray-300 text-sm">
|
|
||||||
<h3 class="inline-block font-semibold text-md mb-4 border-b border-green-700">
|
|
||||||
WakaTime
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="flex space-x-4">
|
|
||||||
<img alt="WakaTime Logo"
|
|
||||||
width="55px"
|
|
||||||
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzQwIiBoZWlnaHQ9IjM0MCIgdmlld0JveD0iMCAwIDM0MCAzNDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJNMTcwIDIwQzg3LjE1NiAyMCAyMCA4Ny4xNTYgMjAgMTcwQzIwIDI1Mi44NDQgODcuMTU2IDMyMCAxNzAgMzIwQzI1Mi44NDQgMzIwIDMyMCAyNTIuODQ0IDMyMCAxNzBDMzIwIDg3LjE1NiAyNTIuODQ0IDIwIDE3MCAyMFYyMFYyMFoiIHN0cm9rZT0id2hpdGUiIHN0cm9rZS13aWR0aD0iNDAiLz4KPHBhdGggZD0iTTE5MC4xODMgMjEzLjU0MUMxODguNzQgMjE1LjQ0MyAxODYuNTc2IDIxNi42NjcgMTg0LjE1MSAyMTYuNjY3QzE4My45MTMgMjE2LjY2NyAxODMuNjc3IDIxNi42NTEgMTgzLjQ0MyAyMTYuNjI3QzE4My4wNDIgMjE2LjU3OSAxODIuODIzIDIxNi41NDUgMTgyLjYwNiAyMTYuNDk3QzE4Mi4zMzcgMjE2LjQzNCAxODIuMTM3IDIxNi4zNzUgMTgxLjk0IDIxNi4zMDhDMTgxLjU2MSAyMTYuMTc2IDE4MS4zOTIgMjE2LjEwOSAxODEuMjI4IDIxNi4wMzVDMTgwLjg0MyAyMTUuODQ5IDE4MC43MDcgMjE1Ljc3OCAxODAuNTcyIDIxNS43MDFDMTgwLjIwNSAyMTUuNDc4IDE4MC4xMDkgMjE1LjQxMiAxODAuMDE0IDIxNS4zNDVDMTc5Ljg1NiAyMTUuMjMzIDE3OS42OTggMjE1LjExNyAxNzkuNTQ3IDIxNC45OTJDMTc5LjI1MSAyMTQuNzQ2IDE3OS4xNDcgMjE0LjY1IDE3OS4wNDQgMjE0LjU1MkMxNzguNzMxIDIxNC4yNDEgMTc4LjUzMSAyMTQuMDE4IDE3OC4zNDEgMjEzLjc4NUMxNzcuOTgyIDIxMy4zMzEgMTc3LjY5IDIxMi44ODggMTc3LjQzOCAyMTIuNDE1TDE2OC42IDE5OC4yMTRMMTU5Ljc2NiAyMTIuNDE1QzE1OC4zOCAyMTQuOTM5IDE1NS44NzQgMjE2LjY2NyAxNTIuOTk1IDIxNi42NjdDMTUwLjEwNiAyMTYuNjY3IDE0Ny41ODggMjE0LjkyNiAxNDYuMjQzIDIxMi4zNDZMMTA3LjYwNyAxNTYuMDYxQzEwNi4zMzcgMTU0LjUyOSAxMDUuNTU2IDE1Mi40OTkgMTA1LjU1NiAxNTAuMjU4QzEwNS41NTYgMTQ1LjUxNCAxMDkuMDQzIDE0MS42NjUgMTEzLjM0NCAxNDEuNjY1QzExNi4xMjcgMTQxLjY2NSAxMTguNTY0IDE0My4yODIgMTE5Ljk0MiAxNDUuNzA4TDE1Mi41NTUgMTkzLjlMMTYxLjczNSAxNzguOTUyQzE2My4wNTggMTc2LjI4OCAxNjUuNjI2IDE3NC40NzggMTY4LjU3NSAxNzQuNDc4QzE3MS4yNzMgMTc0LjQ3OCAxNzMuNjUyIDE3NS45OTYgMTc1LjA0OSAxNzguMjk4TDE4NC41MTcgMTkzLjgzOUwyMzUuNjg0IDEyMC41ODNDMjM3LjA3NSAxMTguMjI2IDIzOS40NzUgMTE2LjY2NyAyNDIuMjEzIDExNi42NjdDMjQ2LjUxNCAxMTYuNjY3IDI1MCAxMjAuNTE0IDI1MCAxMjUuMjU4QzI1MCAxMjcuMzMyIDI0OS4zMzcgMTI5LjIzMiAyNDguMjMgMTMwLjcxNUwxOTAuMTgzIDIxMy41NDFWMjEzLjU0MVoiIGZpbGw9IndoaXRlIiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjEwIi8+Cjwvc3ZnPgo=">
|
|
||||||
<p class="text-sm">You can connect Wakapi with the official <a class="underline"
|
|
||||||
href="https://wakatime.com"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
target="_blank">WakaTime</a> in a way
|
|
||||||
that all heartbeats sent to Wakapi are relayed. This way, you can use both services
|
|
||||||
at
|
|
||||||
the same time. To get started, <a class="underline"
|
|
||||||
href="https://wakatime.com/developers#authentication"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
target="_blank">get your API key</a> and paste it here.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form action="" method="post">
|
<div class="mt-10 text-gray-300 text-sm">
|
||||||
<input type="hidden" name="action" value="toggle_wakatime">
|
<h3 class="font-semibold text-md mb-4 border-b border-green-700 inline-block" id="apikey">
|
||||||
|
Reset API Key
|
||||||
|
</h3>
|
||||||
|
|
||||||
{{ $placeholderText := "Paste your WakaTime API key here ..." }}
|
<form class="mt-2" action="" method="post">
|
||||||
{{ if .User.WakatimeApiKey }}
|
<input type="hidden" name="action" value="reset_apikey">
|
||||||
{{ $placeholderText = "********" }}
|
<div class="text-gray-300 text-sm mb-4">
|
||||||
{{ end }}
|
<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
|
||||||
<div class="flex items-center mt-8 space-x-2">
|
WakaTime
|
||||||
<label class="text-gray-500 font-semibold">API Key:</label>
|
client send heartbeats again.
|
||||||
<input type="password" name="api_key" id="wakatime_api_key"
|
|
||||||
class="flex-grow shadow appearance-nonshadow appearance-none bg-gray-800 text-gray-300 border-green-700 border rounded py-1 px-3 {{ if not .User.WakatimeApiKey }}focus:bg-gray-700 focus:border-gray-500{{ end }}"
|
|
||||||
placeholder="{{ $placeholderText }}" {{ if .User.WakatimeApiKey }}readonly{{ end }}>
|
|
||||||
<div class="flex-grow flex justify-end">
|
|
||||||
{{ if not .User.WakatimeApiKey }}
|
|
||||||
<button type="submit"
|
|
||||||
class="py-1 px-3 my-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
|
||||||
Connect
|
|
||||||
</button>
|
|
||||||
{{ else }}
|
|
||||||
<button type="submit"
|
|
||||||
class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm"
|
|
||||||
style="width: 130px">Disconnect
|
|
||||||
</button>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{{ if .User.WakatimeApiKey }}
|
<div class="flex justify-center">
|
||||||
<div class="flex justify-end">
|
<button type="submit"
|
||||||
<button id="btn-import-wakatime" type="button" style="width: 130px"
|
class="mt-2 py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm">
|
||||||
class="py-1 px-3 my-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
Reset API Key
|
||||||
⤵ Import Data
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 text-gray-300 text-sm">
|
||||||
|
<h3 class="font-semibold text-md mb-4 border-b border-green-700 inline-block">
|
||||||
|
Delete Account
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
Deleting your account will cause all data, including all your heartbeats, to be erased from the
|
||||||
|
server immediately. This action is irreversible. <strong>Be careful!</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex justify-center">
|
||||||
|
<form action="" method="post" id="form-delete-user">
|
||||||
|
<input type="hidden" name="action" value="delete_account">
|
||||||
|
<button type="button" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm"
|
||||||
|
id="btn-confirm-delete-user">
|
||||||
|
Delete my Account
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
{{ end }}
|
</div>
|
||||||
</form>
|
|
||||||
|
|
||||||
<form action="" method="post" id="form-import-wakatime" class="mt-6">
|
|
||||||
<input type="hidden" name="action" value="import_wakatime">
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p class="mt-6">
|
|
||||||
<span class="font-semibold">👉 Please note:</span>
|
|
||||||
<span>When enabling this feature, the operators of this server will, in theory (!), have unlimited access to your data stored in WakaTime. If you are concerned about your privacy, please do not enable this integration or wait for OAuth 2 authentication (<a
|
|
||||||
class="underline" target="_blank" href="https://github.com/muety/wakapi/issues/94"
|
|
||||||
rel="noopener noreferrer">#94</a>) to be implemented.</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
|
|
||||||
|
|
||||||
<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" id="danger">
|
|
||||||
⚠️ Danger Zone
|
|
||||||
</div>
|
|
||||||
<div class="mt-10 text-gray-300 text-sm">
|
|
||||||
<h3 class="font-semibold text-md mb-4 border-b border-green-700 inline-block">
|
|
||||||
Regenerate summaries
|
|
||||||
</h3>
|
|
||||||
<p>
|
|
||||||
Wakapi improves its efficiency and speed by automatically aggregating individual heartbeats to
|
|
||||||
summaries on a per-day basis.
|
|
||||||
That is, historic summaries, i.e. such from past days, are generated once and only fetched from the
|
|
||||||
database in a static fashion afterwards, unless you pass <span
|
|
||||||
class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">&recompute=true</span>
|
|
||||||
with your request.
|
|
||||||
</p>
|
|
||||||
<p class="mt-2">
|
|
||||||
If, for some reason, these aggregated summaries are faulty or preconditions have change (e.g. you
|
|
||||||
modified language mappings retrospectively), you may want to re-generate them from raw heartbeats.
|
|
||||||
</p>
|
|
||||||
<p class="mt-2">
|
|
||||||
<strong>Note:</strong> Only run this action if you know what you are doing. Data might be lost is
|
|
||||||
case heartbeats were deleted after the respective summaries had been generated.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="mt-6 flex justify-center">
|
|
||||||
<form action="" method="post" id="form-regenerate-summaries">
|
|
||||||
<input type="hidden" name="action" value="regenerate_summaries">
|
|
||||||
<button type="button" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm"
|
|
||||||
id="btn-regenerate-summaries">
|
|
||||||
Clear & Regenerate
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-10 text-gray-300 text-sm">
|
|
||||||
<h3 class="font-semibold text-md mb-4 border-b border-green-700 inline-block">
|
|
||||||
Delete Account
|
|
||||||
</h3>
|
|
||||||
<p>
|
|
||||||
Deleting your account will cause all data, including all your heartbeats, to be erased from the
|
|
||||||
server immediately. This action is irreversible. <strong>Be careful!</strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="mt-6 flex justify-center">
|
|
||||||
<form action="" method="post" id="form-delete-user">
|
|
||||||
<input type="hidden" name="action" value="delete_account">
|
|
||||||
<button type="button" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm"
|
|
||||||
id="btn-confirm-delete-user">
|
|
||||||
Delete my Account
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user