binwiederhier 3 лет назад
Родитель
Сommit
c2f16f740b

+ 2 - 5
auth/auth.go

@@ -6,8 +6,8 @@ import (
 	"regexp"
 )
 
-// Auther is a generic interface to implement password and token based authentication and authorization
-type Auther interface {
+// Manager is a generic interface to implement password and token based authentication and authorization
+type Manager interface {
 	// Authenticate checks username and password and returns a user if correct. The method
 	// returns in constant-ish time, regardless of whether the user exists or the password is
 	// correct or incorrect.
@@ -21,10 +21,7 @@ type Auther interface {
 	// Authorize returns nil if the given user has access to the given topic using the desired
 	// permission. The user param may be nil to signal an anonymous user.
 	Authorize(user *User, topic string, perm Permission) error
-}
 
-// Manager is an interface representing user and access management
-type Manager interface {
 	// AddUser adds a user with the given username, password and role. The password should be hashed
 	// before it is stored in a persistence layer.
 	AddUser(username, password string, role Role) error

+ 27 - 28
auth/auth_sqlite.go

@@ -17,7 +17,7 @@ const (
 	intentionalSlowDownHash = "$2a$10$YFCQvqQDwIIwnJM1xkAYOeih0dg17UVGanaTStnrSzC8NCWxcLDwy" // Cost should match bcryptCost
 )
 
-// Auther-related queries
+// Manager-related queries
 const (
 	createAuthTablesQueries = `
 		BEGIN;
@@ -105,19 +105,18 @@ const (
 	selectSchemaVersionQuery = `SELECT version FROM schemaVersion WHERE id = 1`
 )
 
-// SQLiteAuth is an implementation of Auther and Manager. It stores users and access control list
+// SQLiteAuthManager is an implementation of Manager and Manager. It stores users and access control list
 // in a SQLite database.
-type SQLiteAuth struct {
+type SQLiteAuthManager struct {
 	db           *sql.DB
 	defaultRead  bool
 	defaultWrite bool
 }
 
-var _ Auther = (*SQLiteAuth)(nil)
-var _ Manager = (*SQLiteAuth)(nil)
+var _ Manager = (*SQLiteAuthManager)(nil)
 
-// NewSQLiteAuth creates a new SQLiteAuth instance
-func NewSQLiteAuth(filename string, defaultRead, defaultWrite bool) (*SQLiteAuth, error) {
+// NewSQLiteAuthManager creates a new SQLiteAuthManager instance
+func NewSQLiteAuthManager(filename string, defaultRead, defaultWrite bool) (*SQLiteAuthManager, error) {
 	db, err := sql.Open("sqlite3", filename)
 	if err != nil {
 		return nil, err
@@ -125,7 +124,7 @@ func NewSQLiteAuth(filename string, defaultRead, defaultWrite bool) (*SQLiteAuth
 	if err := setupAuthDB(db); err != nil {
 		return nil, err
 	}
-	return &SQLiteAuth{
+	return &SQLiteAuthManager{
 		db:           db,
 		defaultRead:  defaultRead,
 		defaultWrite: defaultWrite,
@@ -135,7 +134,7 @@ func NewSQLiteAuth(filename string, defaultRead, defaultWrite bool) (*SQLiteAuth
 // Authenticate checks username and password and returns a user if correct. The method
 // returns in constant-ish time, regardless of whether the user exists or the password is
 // correct or incorrect.
-func (a *SQLiteAuth) Authenticate(username, password string) (*User, error) {
+func (a *SQLiteAuthManager) Authenticate(username, password string) (*User, error) {
 	if username == Everyone {
 		return nil, ErrUnauthenticated
 	}
@@ -151,7 +150,7 @@ func (a *SQLiteAuth) Authenticate(username, password string) (*User, error) {
 	return user, nil
 }
 
-func (a *SQLiteAuth) AuthenticateToken(token string) (*User, error) {
+func (a *SQLiteAuthManager) AuthenticateToken(token string) (*User, error) {
 	user, err := a.userByToken(token)
 	if err != nil {
 		return nil, ErrUnauthenticated
@@ -160,7 +159,7 @@ func (a *SQLiteAuth) AuthenticateToken(token string) (*User, error) {
 	return user, nil
 }
 
-func (a *SQLiteAuth) CreateToken(user *User) (string, error) {
+func (a *SQLiteAuthManager) CreateToken(user *User) (string, error) {
 	token := util.RandomString(tokenLength)
 	expires := 1 // FIXME
 	if _, err := a.db.Exec(insertTokenQuery, user.Name, token, expires); err != nil {
@@ -169,7 +168,7 @@ func (a *SQLiteAuth) CreateToken(user *User) (string, error) {
 	return token, nil
 }
 
-func (a *SQLiteAuth) RemoveToken(user *User) error {
+func (a *SQLiteAuthManager) RemoveToken(user *User) error {
 	if user.Token == "" {
 		return ErrUnauthorized
 	}
@@ -179,7 +178,7 @@ func (a *SQLiteAuth) RemoveToken(user *User) error {
 	return nil
 }
 
-func (a *SQLiteAuth) ChangeSettings(user *User) error {
+func (a *SQLiteAuthManager) ChangeSettings(user *User) error {
 	settings, err := json.Marshal(user.Prefs)
 	if err != nil {
 		return err
@@ -192,7 +191,7 @@ func (a *SQLiteAuth) ChangeSettings(user *User) error {
 
 // Authorize returns nil if the given user has access to the given topic using the desired
 // permission. The user param may be nil to signal an anonymous user.
-func (a *SQLiteAuth) Authorize(user *User, topic string, perm Permission) error {
+func (a *SQLiteAuthManager) Authorize(user *User, topic string, perm Permission) error {
 	if user != nil && user.Role == RoleAdmin {
 		return nil // Admin can do everything
 	}
@@ -220,7 +219,7 @@ func (a *SQLiteAuth) Authorize(user *User, topic string, perm Permission) error
 	return a.resolvePerms(read, write, perm)
 }
 
-func (a *SQLiteAuth) resolvePerms(read, write bool, perm Permission) error {
+func (a *SQLiteAuthManager) resolvePerms(read, write bool, perm Permission) error {
 	if perm == PermissionRead && read {
 		return nil
 	} else if perm == PermissionWrite && write {
@@ -231,7 +230,7 @@ func (a *SQLiteAuth) resolvePerms(read, write bool, perm Permission) error {
 
 // AddUser adds a user with the given username, password and role. The password should be hashed
 // before it is stored in a persistence layer.
-func (a *SQLiteAuth) AddUser(username, password string, role Role) error {
+func (a *SQLiteAuthManager) AddUser(username, password string, role Role) error {
 	if !AllowedUsername(username) || !AllowedRole(role) {
 		return ErrInvalidArgument
 	}
@@ -247,7 +246,7 @@ func (a *SQLiteAuth) AddUser(username, password string, role Role) error {
 
 // RemoveUser deletes the user with the given username. The function returns nil on success, even
 // if the user did not exist in the first place.
-func (a *SQLiteAuth) RemoveUser(username string) error {
+func (a *SQLiteAuthManager) RemoveUser(username string) error {
 	if !AllowedUsername(username) {
 		return ErrInvalidArgument
 	}
@@ -261,7 +260,7 @@ func (a *SQLiteAuth) RemoveUser(username string) error {
 }
 
 // Users returns a list of users. It always also returns the Everyone user ("*").
-func (a *SQLiteAuth) Users() ([]*User, error) {
+func (a *SQLiteAuthManager) Users() ([]*User, error) {
 	rows, err := a.db.Query(selectUsernamesQuery)
 	if err != nil {
 		return nil, err
@@ -296,7 +295,7 @@ func (a *SQLiteAuth) Users() ([]*User, error) {
 
 // User returns the user with the given username if it exists, or ErrNotFound otherwise.
 // You may also pass Everyone to retrieve the anonymous user and its Grant list.
-func (a *SQLiteAuth) User(username string) (*User, error) {
+func (a *SQLiteAuthManager) User(username string) (*User, error) {
 	if username == Everyone {
 		return a.everyoneUser()
 	}
@@ -307,7 +306,7 @@ func (a *SQLiteAuth) User(username string) (*User, error) {
 	return a.readUser(rows)
 }
 
-func (a *SQLiteAuth) userByToken(token string) (*User, error) {
+func (a *SQLiteAuthManager) userByToken(token string) (*User, error) {
 	rows, err := a.db.Query(selectUserByTokenQuery, token)
 	if err != nil {
 		return nil, err
@@ -315,7 +314,7 @@ func (a *SQLiteAuth) userByToken(token string) (*User, error) {
 	return a.readUser(rows)
 }
 
-func (a *SQLiteAuth) readUser(rows *sql.Rows) (*User, error) {
+func (a *SQLiteAuthManager) readUser(rows *sql.Rows) (*User, error) {
 	defer rows.Close()
 	var username, hash, role string
 	var prefs sql.NullString
@@ -346,7 +345,7 @@ func (a *SQLiteAuth) readUser(rows *sql.Rows) (*User, error) {
 	return user, nil
 }
 
-func (a *SQLiteAuth) everyoneUser() (*User, error) {
+func (a *SQLiteAuthManager) everyoneUser() (*User, error) {
 	grants, err := a.readGrants(Everyone)
 	if err != nil {
 		return nil, err
@@ -359,7 +358,7 @@ func (a *SQLiteAuth) everyoneUser() (*User, error) {
 	}, nil
 }
 
-func (a *SQLiteAuth) readGrants(username string) ([]Grant, error) {
+func (a *SQLiteAuthManager) readGrants(username string) ([]Grant, error) {
 	rows, err := a.db.Query(selectUserAccessQuery, username)
 	if err != nil {
 		return nil, err
@@ -384,7 +383,7 @@ func (a *SQLiteAuth) readGrants(username string) ([]Grant, error) {
 }
 
 // ChangePassword changes a user's password
-func (a *SQLiteAuth) ChangePassword(username, password string) error {
+func (a *SQLiteAuthManager) ChangePassword(username, password string) error {
 	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
 	if err != nil {
 		return err
@@ -397,7 +396,7 @@ func (a *SQLiteAuth) ChangePassword(username, password string) error {
 
 // ChangeRole changes a user's role. When a role is changed from RoleUser to RoleAdmin,
 // all existing access control entries (Grant) are removed, since they are no longer needed.
-func (a *SQLiteAuth) ChangeRole(username string, role Role) error {
+func (a *SQLiteAuthManager) ChangeRole(username string, role Role) error {
 	if !AllowedUsername(username) || !AllowedRole(role) {
 		return ErrInvalidArgument
 	}
@@ -414,7 +413,7 @@ func (a *SQLiteAuth) ChangeRole(username string, role Role) error {
 
 // AllowAccess adds or updates an entry in th access control list for a specific user. It controls
 // read/write access to a topic. The parameter topicPattern may include wildcards (*).
-func (a *SQLiteAuth) AllowAccess(username string, topicPattern string, read bool, write bool) error {
+func (a *SQLiteAuthManager) AllowAccess(username string, topicPattern string, read bool, write bool) error {
 	if (!AllowedUsername(username) && username != Everyone) || !AllowedTopicPattern(topicPattern) {
 		return ErrInvalidArgument
 	}
@@ -426,7 +425,7 @@ func (a *SQLiteAuth) AllowAccess(username string, topicPattern string, read bool
 
 // ResetAccess removes an access control list entry for a specific username/topic, or (if topic is
 // empty) for an entire user. The parameter topicPattern may include wildcards (*).
-func (a *SQLiteAuth) ResetAccess(username string, topicPattern string) error {
+func (a *SQLiteAuthManager) ResetAccess(username string, topicPattern string) error {
 	if !AllowedUsername(username) && username != Everyone && username != "" {
 		return ErrInvalidArgument
 	} else if !AllowedTopicPattern(topicPattern) && topicPattern != "" {
@@ -444,7 +443,7 @@ func (a *SQLiteAuth) ResetAccess(username string, topicPattern string) error {
 }
 
 // DefaultAccess returns the default read/write access if no access control entry matches
-func (a *SQLiteAuth) DefaultAccess() (read bool, write bool) {
+func (a *SQLiteAuthManager) DefaultAccess() (read bool, write bool) {
 	return a.defaultRead, a.defaultWrite
 }
 

+ 2 - 2
auth/auth_sqlite_test.go

@@ -235,9 +235,9 @@ func TestSQLiteAuth_ChangeRole(t *testing.T) {
 	require.Equal(t, 0, len(ben.Grants))
 }
 
-func newTestAuth(t *testing.T, defaultRead, defaultWrite bool) *auth.SQLiteAuth {
+func newTestAuth(t *testing.T, defaultRead, defaultWrite bool) *auth.SQLiteAuthManager {
 	filename := filepath.Join(t.TempDir(), "user.db")
-	a, err := auth.NewSQLiteAuth(filename, defaultRead, defaultWrite)
+	a, err := auth.NewSQLiteAuthManager(filename, defaultRead, defaultWrite)
 	require.Nil(t, err)
 	return a
 }

+ 6 - 0
cmd/serve.go

@@ -74,6 +74,8 @@ var flagsServe = append(
 	altsrc.NewIntFlag(&cli.IntFlag{Name: "visitor-email-limit-burst", Aliases: []string{"visitor_email_limit_burst"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_BURST"}, Value: server.DefaultVisitorEmailLimitBurst, Usage: "initial limit of e-mails per visitor"}),
 	altsrc.NewDurationFlag(&cli.DurationFlag{Name: "visitor-email-limit-replenish", Aliases: []string{"visitor_email_limit_replenish"}, EnvVars: []string{"NTFY_VISITOR_EMAIL_LIMIT_REPLENISH"}, Value: server.DefaultVisitorEmailLimitReplenish, Usage: "interval at which burst limit is replenished (one per x)"}),
 	altsrc.NewBoolFlag(&cli.BoolFlag{Name: "behind-proxy", Aliases: []string{"behind_proxy", "P"}, EnvVars: []string{"NTFY_BEHIND_PROXY"}, Value: false, Usage: "if set, use X-Forwarded-For header to determine visitor IP address (for rate limiting)"}),
+	altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-signup", Aliases: []string{"enable_signup"}, EnvVars: []string{"NTFY_ENABLE_SIGNUP"}, Value: false, Usage: "xxx"}),
+	altsrc.NewBoolFlag(&cli.BoolFlag{Name: "enable-login", Aliases: []string{"enable_login"}, EnvVars: []string{"NTFY_ENABLE_LOGIN"}, Value: false, Usage: "xxx"}),
 )
 
 var cmdServe = &cli.Command{
@@ -141,6 +143,8 @@ func execServe(c *cli.Context) error {
 	visitorEmailLimitBurst := c.Int("visitor-email-limit-burst")
 	visitorEmailLimitReplenish := c.Duration("visitor-email-limit-replenish")
 	behindProxy := c.Bool("behind-proxy")
+	enableSignup := c.Bool("enable-signup")
+	enableLogin := c.Bool("enable-login")
 
 	// Check values
 	if firebaseKeyFile != "" && !util.FileExists(firebaseKeyFile) {
@@ -268,6 +272,8 @@ func execServe(c *cli.Context) error {
 	conf.VisitorEmailLimitReplenish = visitorEmailLimitReplenish
 	conf.BehindProxy = behindProxy
 	conf.EnableWeb = enableWeb
+	conf.EnableSignup = enableSignup
+	conf.EnableLogin = enableLogin
 	conf.Version = c.App.Version
 
 	// Set up hot-reloading of config

+ 1 - 1
cmd/user.go

@@ -278,7 +278,7 @@ func createAuthManager(c *cli.Context) (auth.Manager, error) {
 	}
 	authDefaultRead := authDefaultAccess == "read-write" || authDefaultAccess == "read-only"
 	authDefaultWrite := authDefaultAccess == "read-write" || authDefaultAccess == "write-only"
-	return auth.NewSQLiteAuth(authFile, authDefaultRead, authDefaultWrite)
+	return auth.NewSQLiteAuthManager(authFile, authDefaultRead, authDefaultWrite)
 }
 
 func readPasswordAndConfirm(c *cli.Context) (string, error) {

+ 4 - 0
server/config.go

@@ -100,6 +100,10 @@ type Config struct {
 	VisitorEmailLimitReplenish           time.Duration
 	BehindProxy                          bool
 	EnableWeb                            bool
+	EnableSignup                         bool
+	EnableLogin                          bool
+	EnableEmailConfirm                   bool
+	EnableResetPassword                  bool
 	Version                              string // injected by App
 }
 

+ 64 - 63
server/server.go

@@ -38,10 +38,7 @@ import (
 	TODO
 		expire tokens
 		auto-refresh tokens from UI
-		pricing page
-		home page
 		reserve topics
-
 		Pages:
 		- Home
 		- Signup
@@ -52,11 +49,6 @@ import (
 		- change email
 		-
 
-		Config flags:
-		-
-		- enable-register: true|false
-		- enable-login: true|false
-		- enable-reset-password: true|false
 
 
 */
@@ -74,7 +66,7 @@ type Server struct {
 	visitors          map[string]*visitor // ip:<ip> or user:<user>
 	firebaseClient    *firebaseClient
 	messages          int64
-	auth              auth.Auther
+	auth              auth.Manager
 	messageCache      *messageCache
 	fileCache         *fileCache
 	closeChan         chan bool
@@ -96,18 +88,19 @@ var (
 	authPathRegex          = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}(,[-_A-Za-z0-9]{1,64})*/auth$`)
 	publishPathRegex       = regexp.MustCompile(`^/[-_A-Za-z0-9]{1,64}/(publish|send|trigger)$`)
 
-	webConfigPath               = "/config.js"
-	userStatsPath               = "/user/stats" // FIXME get rid of this in favor of /user/account
-	userTokenPath               = "/user/token"
-	userAccountPath             = "/user/account"
-	userSubscriptionPath        = "/user/subscription"
-	userSubscriptionDeleteRegex = regexp.MustCompile(`^/user/subscription/([-_A-Za-z0-9]{16})$`)
-	matrixPushPath              = "/_matrix/push/v1/notify"
-	staticRegex                 = regexp.MustCompile(`^/static/.+`)
-	docsRegex                   = regexp.MustCompile(`^/docs(|/.*)$`)
-	fileRegex                   = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
-	disallowedTopics            = []string{"docs", "static", "file", "app", "settings"} // If updated, also update in Android app
-	urlRegex                    = regexp.MustCompile(`^https?://`)
+	webConfigPath                  = "/config.js"
+	userStatsPath                  = "/user/stats" // FIXME get rid of this in favor of /user/account
+	accountPath                    = "/v1/account"
+	accountTokenPath               = "/v1/account/token"
+	accountSettingsPath            = "/v1/account/settings"
+	accountSubscriptionPath        = "/v1/account/subscription"
+	accountSubscriptionSingleRegex = regexp.MustCompile(`^/v1/account/subscription/([-_A-Za-z0-9]{16})$`)
+	matrixPushPath                 = "/_matrix/push/v1/notify"
+	staticRegex                    = regexp.MustCompile(`^/static/.+`)
+	docsRegex                      = regexp.MustCompile(`^/docs(|/.*)$`)
+	fileRegex                      = regexp.MustCompile(`^/file/([-_A-Za-z0-9]{1,64})(?:\.[A-Za-z0-9]{1,16})?$`)
+	disallowedTopics               = []string{"docs", "static", "file", "app", "settings"} // If updated, also update in Android app
+	urlRegex                       = regexp.MustCompile(`^https?://`)
 
 	//go:embed site
 	webFs        embed.FS
@@ -160,9 +153,9 @@ func New(conf *Config) (*Server, error) {
 			return nil, err
 		}
 	}
-	var auther auth.Auther
+	var auther auth.Manager
 	if conf.AuthFile != "" {
-		auther, err = auth.NewSQLiteAuth(conf.AuthFile, conf.AuthDefaultRead, conf.AuthDefaultWrite)
+		auther, err = auth.NewSQLiteAuthManager(conf.AuthFile, conf.AuthDefaultRead, conf.AuthDefaultWrite)
 		if err != nil {
 			return nil, err
 		}
@@ -335,18 +328,20 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit
 		return s.ensureWebEnabled(s.handleWebConfig)(w, r, v)
 	} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath {
 		return s.handleUserStats(w, r, v)
-	} else if r.Method == http.MethodGet && r.URL.Path == userTokenPath {
-		return s.handleUserTokenCreate(w, r, v)
-	} else if r.Method == http.MethodDelete && r.URL.Path == userTokenPath {
-		return s.handleUserTokenDelete(w, r, v)
-	} else if r.Method == http.MethodGet && r.URL.Path == userAccountPath {
-		return s.handleUserAccount(w, r, v)
-	} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == userAccountPath {
-		return s.handleUserAccountUpdate(w, r, v)
-	} else if (r.Method == http.MethodPut || r.Method == http.MethodPost) && r.URL.Path == userSubscriptionPath {
-		return s.handleUserSubscriptionAdd(w, r, v)
-	} else if r.Method == http.MethodDelete && userSubscriptionDeleteRegex.MatchString(r.URL.Path) {
-		return s.handleUserSubscriptionDelete(w, r, v)
+	} else if r.Method == http.MethodPost && r.URL.Path == accountPath {
+		return s.handleUserAccountCreate(w, r, v)
+	} else if r.Method == http.MethodGet && r.URL.Path == accountTokenPath {
+		return s.handleAccountTokenGet(w, r, v)
+	} else if r.Method == http.MethodDelete && r.URL.Path == accountTokenPath {
+		return s.handleAccountTokenDelete(w, r, v)
+	} else if r.Method == http.MethodGet && r.URL.Path == accountSettingsPath {
+		return s.handleAccountSettingsGet(w, r, v)
+	} else if r.Method == http.MethodPost && r.URL.Path == accountSettingsPath {
+		return s.handleAccountSettingsPost(w, r, v)
+	} else if r.Method == http.MethodPost && r.URL.Path == accountSubscriptionPath {
+		return s.handleAccountSubscriptionAdd(w, r, v)
+	} else if r.Method == http.MethodDelete && accountSubscriptionSingleRegex.MatchString(r.URL.Path) {
+		return s.handleAccountSubscriptionDelete(w, r, v)
 	} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath {
 		return s.handleMatrixDiscovery(w)
 	} else if r.Method == http.MethodGet && staticRegex.MatchString(r.URL.Path) {
@@ -441,11 +436,7 @@ func (s *Server) handleUserStats(w http.ResponseWriter, r *http.Request, v *visi
 	return nil
 }
 
-type tokenAuthResponse struct {
-	Token string `json:"token"`
-}
-
-func (s *Server) handleUserTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
+func (s *Server) handleAccountTokenGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
 	// TODO rate limit
 	if v.user == nil {
 		return errHTTPUnauthorized
@@ -456,7 +447,7 @@ func (s *Server) handleUserTokenCreate(w http.ResponseWriter, r *http.Request, v
 	}
 	w.Header().Set("Content-Type", "application/json")
 	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
-	response := &tokenAuthResponse{
+	response := &apiAccountTokenResponse{
 		Token: token,
 	}
 	if err := json.NewEncoder(w).Encode(response); err != nil {
@@ -465,7 +456,7 @@ func (s *Server) handleUserTokenCreate(w http.ResponseWriter, r *http.Request, v
 	return nil
 }
 
-func (s *Server) handleUserTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
+func (s *Server) handleAccountTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
 	// TODO rate limit
 	if v.user == nil || v.user.Token == "" {
 		return errHTTPUnauthorized
@@ -477,24 +468,10 @@ func (s *Server) handleUserTokenDelete(w http.ResponseWriter, r *http.Request, v
 	return nil
 }
 
-type userPlanResponse struct {
-	Id   int    `json:"id"`
-	Name string `json:"name"`
-}
-
-type userAccountResponse struct {
-	Username      string                      `json:"username"`
-	Role          string                      `json:"role,omitempty"`
-	Plan          *userPlanResponse           `json:"plan,omitempty"`
-	Language      string                      `json:"language,omitempty"`
-	Notification  *auth.UserNotificationPrefs `json:"notification,omitempty"`
-	Subscriptions []*auth.UserSubscription    `json:"subscriptions,omitempty"`
-}
-
-func (s *Server) handleUserAccount(w http.ResponseWriter, r *http.Request, v *visitor) error {
+func (s *Server) handleAccountSettingsGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
 	w.Header().Set("Content-Type", "application/json")
 	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
-	response := &userAccountResponse{}
+	response := &apiAccountSettingsResponse{}
 	if v.user != nil {
 		response.Username = v.user.Name
 		response.Role = string(v.user.Role)
@@ -510,7 +487,7 @@ func (s *Server) handleUserAccount(w http.ResponseWriter, r *http.Request, v *vi
 			}
 		}
 	} else {
-		response = &userAccountResponse{
+		response = &apiAccountSettingsResponse{
 			Username: auth.Everyone,
 			Role:     string(auth.RoleAnonymous),
 		}
@@ -521,7 +498,31 @@ func (s *Server) handleUserAccount(w http.ResponseWriter, r *http.Request, v *vi
 	return nil
 }
 
-func (s *Server) handleUserAccountUpdate(w http.ResponseWriter, r *http.Request, v *visitor) error {
+func (s *Server) handleUserAccountCreate(w http.ResponseWriter, r *http.Request, v *visitor) error {
+	signupAllowed := s.config.EnableSignup
+	admin := v.user != nil && v.user.Role == auth.RoleAdmin
+	if !signupAllowed && !admin {
+		return errHTTPUnauthorized
+	}
+	body, err := util.Peek(r.Body, 4096) // FIXME
+	if err != nil {
+		return err
+	}
+	defer r.Body.Close()
+	var newAccount apiAccountCreateRequest
+	if err := json.NewDecoder(body).Decode(&newAccount); err != nil {
+		return err
+	}
+	if err := s.auth.AddUser(newAccount.Username, newAccount.Password, auth.RoleUser); err != nil { // TODO this should return a User
+		return err
+	}
+	w.Header().Set("Content-Type", "application/json")
+	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
+	// FIXME return something
+	return nil
+}
+
+func (s *Server) handleAccountSettingsPost(w http.ResponseWriter, r *http.Request, v *visitor) error {
 	if v.user == nil {
 		return errors.New("no user")
 	}
@@ -560,7 +561,7 @@ func (s *Server) handleUserAccountUpdate(w http.ResponseWriter, r *http.Request,
 	return s.auth.ChangeSettings(v.user)
 }
 
-func (s *Server) handleUserSubscriptionAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
+func (s *Server) handleAccountSubscriptionAdd(w http.ResponseWriter, r *http.Request, v *visitor) error {
 	if v.user == nil {
 		return errors.New("no user")
 	}
@@ -598,13 +599,13 @@ func (s *Server) handleUserSubscriptionAdd(w http.ResponseWriter, r *http.Reques
 	return nil
 }
 
-func (s *Server) handleUserSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
+func (s *Server) handleAccountSubscriptionDelete(w http.ResponseWriter, r *http.Request, v *visitor) error {
 	if v.user == nil {
 		return errors.New("no user")
 	}
 	w.Header().Set("Content-Type", "application/json")
 	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this
-	matches := userSubscriptionDeleteRegex.FindStringSubmatch(r.URL.Path)
+	matches := accountSubscriptionSingleRegex.FindStringSubmatch(r.URL.Path)
 	if len(matches) != 2 {
 		return errHTTPInternalErrorInvalidFilePath // FIXME
 	}

+ 3 - 3
server/server_firebase.go

@@ -28,10 +28,10 @@ var (
 // The actual Firebase implementation is implemented in firebaseSenderImpl, to make it testable.
 type firebaseClient struct {
 	sender firebaseSender
-	auther auth.Auther
+	auther auth.Manager
 }
 
-func newFirebaseClient(sender firebaseSender, auther auth.Auther) *firebaseClient {
+func newFirebaseClient(sender firebaseSender, auther auth.Manager) *firebaseClient {
 	return &firebaseClient{
 		sender: sender,
 		auther: auther,
@@ -112,7 +112,7 @@ func (c *firebaseSenderImpl) Send(m *messaging.Message) error {
 //     On Android, this will trigger the app to poll the topic and thereby displaying new messages.
 //   - If UpstreamBaseURL is set, messages are forwarded as poll requests to an upstream server and then forwarded
 //     to Firebase here. This is mainly for iOS to support self-hosted servers.
-func toFirebaseMessage(m *message, auther auth.Auther) (*messaging.Message, error) {
+func toFirebaseMessage(m *message, auther auth.Manager) (*messaging.Message, error) {
 	var data map[string]string // Mostly matches https://ntfy.sh/docs/subscribe/api/#json-message-format
 	var apnsConfig *messaging.APNSConfig
 	switch m.Event {

+ 24 - 0
server/types.go

@@ -1,6 +1,7 @@
 package server
 
 import (
+	"heckel.io/ntfy/auth"
 	"net/http"
 	"net/netip"
 	"time"
@@ -213,3 +214,26 @@ func (q *queryFilter) Pass(msg *message) bool {
 	}
 	return true
 }
+
+type apiAccountCreateRequest struct {
+	Username string `json:"username"`
+	Password string `json:"password"`
+}
+
+type apiAccountTokenResponse struct {
+	Token string `json:"token"`
+}
+
+type apiAccountSettingsPlan struct {
+	Id   int    `json:"id"`
+	Name string `json:"name"`
+}
+
+type apiAccountSettingsResponse struct {
+	Username      string                      `json:"username"`
+	Role          string                      `json:"role,omitempty"`
+	Plan          *apiAccountSettingsPlan     `json:"plan,omitempty"`
+	Language      string                      `json:"language,omitempty"`
+	Notification  *auth.UserNotificationPrefs `json:"notification,omitempty"`
+	Subscriptions []*auth.UserSubscription    `json:"subscriptions,omitempty"`
+}

+ 30 - 36
web/public/static/css/home.css

@@ -1,6 +1,6 @@
 /* general styling */
 
-html, body {
+#site {
     font-family: 'Roboto', sans-serif;
     font-weight: 400;
     font-size: 1.1em;
@@ -9,22 +9,16 @@ html, body {
     padding: 0;
 }
 
-html {
-    /* prevent scrollbar from repositioning website:
-     * https://www.w3docs.com/snippets/css/how-to-prevent-scrollbar-from-repositioning-web-page.html */
-    overflow-y: scroll;
-}
-
-a, a:visited {
+#site a, a:visited {
     color: #338574;
 }
 
-a:hover {
+#site a:hover {
     text-decoration: none;
     color: #317f6f;
 }
 
-h1 {
+#site h1 {
     margin-top: 35px;
     margin-bottom: 30px;
     font-size: 2.5em;
@@ -34,7 +28,7 @@ h1 {
     color: #666;
 }
 
-h2 {
+#site h2 {
     margin-top: 30px;
     margin-bottom: 5px;
     font-size: 1.8em;
@@ -42,7 +36,7 @@ h2 {
     color: #333;
 }
 
-h3 {
+#site h3 {
     margin-top: 25px;
     margin-bottom: 5px;
     font-size: 1.3em;
@@ -50,28 +44,28 @@ h3 {
     color: #333;
 }
 
-p {
+#site p {
     margin-top: 10px;
     margin-bottom: 20px;
     line-height: 160%;
     font-weight: 400;
 }
 
-p.smallMarginBottom {
+#site p.smallMarginBottom {
     margin-bottom: 10px;
 }
 
-b {
+#site b {
     font-weight: 500;
 }
 
-tt {
+#site tt {
     background: #eee;
     padding: 2px 7px;
     border-radius: 3px;
 }
 
-code {
+#site code {
     display: block;
     background: #eee;
     font-family: monospace;
@@ -85,18 +79,18 @@ code {
 
 /* Main page */
 
-#main {
+#site #main {
     max-width: 900px;
     margin: 0 auto 50px auto;
     padding: 0 10px;
 }
 
-#error {
+#site #error {
     color: darkred;
     font-style: italic;
 }
 
-#ironicCenterTagDontFreakOut {
+#site #ironicCenterTagDontFreakOut {
     color: #666;
 }
 
@@ -120,22 +114,22 @@ code {
 
 /* Figures */
 
-figure {
+#site figure {
     text-align: center;
 }
 
-figure img, figure video {
+#site figure img, figure video {
     filter: drop-shadow(3px 3px 3px #ccc);
     border-radius: 7px;
     max-width: 100%;
 }
 
-figure video {
+#site figure video {
     width: 100%;
     max-height: 450px;
 }
 
-figcaption {
+#site figcaption {
     text-align: center;
     font-style: italic;
     padding-top: 10px;
@@ -143,18 +137,18 @@ figcaption {
 
 /* Screenshots */
 
-#screenshots {
+#site #screenshots {
     text-align: center;
 }
 
-#screenshots img {
+#site #screenshots img {
     height: 190px;
     margin: 3px;
     border-radius: 5px;
     filter: drop-shadow(2px 2px 2px #ddd);
 }
 
-#screenshots .nowrap {
+#site #screenshots .nowrap {
     white-space: nowrap;
 }
 
@@ -220,23 +214,23 @@ figcaption {
 
 /* Header */
 
-#header {
+#site #header {
     background: #338574;
     height: 130px;
 }
 
-#header #headerBox {
+#site #header #headerBox {
     max-width: 900px;
     margin: 0 auto;
     padding: 0 10px;
 }
 
-#header #logo {
+#site #header #logo {
     margin-top: 23px;
     float: left;
 }
 
-#header #name {
+#site #header #name {
     float: left;
     color: white;
     font-size: 2.6em;
@@ -244,28 +238,28 @@ figcaption {
     margin: 35px 0 0 20px;
 }
 
-#header ol {
+#site #header ol {
     list-style-type: none;
     float: right;
     margin-top: 80px;
 }
 
-#header ol li {
+#site #header ol li {
     display: inline-block;
     margin: 0 10px;
     font-weight: 400;
 }
 
-#header ol li a, nav ol li a:visited {
+#site #header ol li a, nav ol li a:visited {
     color: white;
     text-decoration: none;
 }
 
-#header ol li a:hover {
+#site #header ol li a:hover {
     text-decoration: underline;
 }
 
-li {
+#site li {
     padding: 4px 0;
     margin: 4px 0;
     font-size: 0.9em;

+ 29 - 13
web/src/app/Api.js

@@ -6,9 +6,9 @@ import {
     topicUrlAuth,
     topicUrlJsonPoll,
     topicUrlJsonPollWithSince,
-    userAccountUrl,
-    userTokenUrl,
-    userStatsUrl, userSubscriptionUrl, userSubscriptionDeleteUrl
+    accountSettingsUrl,
+    accountTokenUrl,
+    userStatsUrl, accountSubscriptionUrl, accountSubscriptionSingleUrl, accountUrl
 } from "./utils";
 import userManager from "./UserManager";
 
@@ -120,7 +120,7 @@ class Api {
     }
 
     async login(baseUrl, user) {
-        const url = userTokenUrl(baseUrl);
+        const url = accountTokenUrl(baseUrl);
         console.log(`[Api] Checking auth for ${url}`);
         const response = await fetch(url, {
             headers: maybeWithBasicAuth({}, user)
@@ -136,7 +136,7 @@ class Api {
     }
 
     async logout(baseUrl, token) {
-        const url = userTokenUrl(baseUrl);
+        const url = accountTokenUrl(baseUrl);
         console.log(`[Api] Logging out from ${url} using token ${token}`);
         const response = await fetch(url, {
             method: "DELETE",
@@ -159,8 +159,24 @@ class Api {
         return stats;
     }
 
-    async userAccount(baseUrl, token) {
-        const url = userAccountUrl(baseUrl);
+    async createAccount(baseUrl, username, password) {
+        const url = accountUrl(baseUrl);
+        const body = JSON.stringify({
+            username: username,
+            password: password
+        });
+        console.log(`[Api] Creating user account ${url}`);
+        const response = await fetch(url, {
+            method: "POST",
+            body: body
+        });
+        if (response.status !== 200) {
+            throw new Error(`Unexpected server response ${response.status}`);
+        }
+    }
+
+    async getAccountSettings(baseUrl, token) {
+        const url = accountSettingsUrl(baseUrl);
         console.log(`[Api] Fetching user account ${url}`);
         const response = await fetch(url, {
             headers: maybeWithBearerAuth({}, token)
@@ -173,8 +189,8 @@ class Api {
         return account;
     }
 
-    async updateUserAccount(baseUrl, token, payload) {
-        const url = userAccountUrl(baseUrl);
+    async updateAccountSettings(baseUrl, token, payload) {
+        const url = accountSettingsUrl(baseUrl);
         const body = JSON.stringify(payload);
         console.log(`[Api] Updating user account ${url}: ${body}`);
         const response = await fetch(url, {
@@ -187,8 +203,8 @@ class Api {
         }
     }
 
-    async userSubscriptionAdd(baseUrl, token, payload) {
-        const url = userSubscriptionUrl(baseUrl);
+    async addAccountSubscription(baseUrl, token, payload) {
+        const url = accountSubscriptionUrl(baseUrl);
         const body = JSON.stringify(payload);
         console.log(`[Api] Adding user subscription ${url}: ${body}`);
         const response = await fetch(url, {
@@ -204,8 +220,8 @@ class Api {
         return subscription;
     }
 
-    async userSubscriptionDelete(baseUrl, token, remoteId) {
-        const url = userSubscriptionDeleteUrl(baseUrl, remoteId);
+    async deleteAccountSubscription(baseUrl, token, remoteId) {
+        const url = accountSubscriptionSingleUrl(baseUrl, remoteId);
         console.log(`[Api] Removing user subscription ${url}`);
         const response = await fetch(url, {
             method: "DELETE",

+ 5 - 4
web/src/app/utils.js

@@ -19,10 +19,11 @@ export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJ
 export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`;
 export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic));
 export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`;
-export const userTokenUrl = (baseUrl) => `${baseUrl}/user/token`;
-export const userAccountUrl = (baseUrl) => `${baseUrl}/user/account`;
-export const userSubscriptionUrl = (baseUrl) => `${baseUrl}/user/subscription`;
-export const userSubscriptionDeleteUrl = (baseUrl, id) => `${baseUrl}/user/subscription/${id}`;
+export const accountUrl = (baseUrl) => `${baseUrl}/v1/account`;
+export const accountTokenUrl = (baseUrl) => `${baseUrl}/v1/account/token`;
+export const accountSettingsUrl = (baseUrl) => `${baseUrl}/v1/account/settings`;
+export const accountSubscriptionUrl = (baseUrl) => `${baseUrl}/v1/account/subscription`;
+export const accountSubscriptionSingleUrl = (baseUrl, id) => `${baseUrl}/v1/account/subscription/${id}`;
 export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, "");
 export const expandUrl = (url) => [`https://${url}`, `http://${url}`];
 export const expandSecureUrl = (url) => `https://${url}`;

+ 1 - 1
web/src/components/ActionBar.js

@@ -115,7 +115,7 @@ const SettingsIcons = (props) => {
         handleClose(event);
         await subscriptionManager.remove(props.subscription.id);
         if (session.exists() && props.subscription.remoteId) {
-            await api.userSubscriptionDelete("http://localhost:2586", session.token(), props.subscription.remoteId);
+            await api.deleteAccountSubscription("http://localhost:2586", session.token(), props.subscription.remoteId);
         }
         const newSelected = await subscriptionManager.first(); // May be undefined
         if (newSelected) {

+ 1 - 1
web/src/components/App.js

@@ -91,7 +91,7 @@ const Layout = () => {
 
     useEffect(() => {
         (async () => {
-            const account = await api.userAccount("http://localhost:2586", session.token());
+            const account = await api.getAccountSettings("http://localhost:2586", session.token());
             if (account) {
                 if (account.language) {
                     await i18n.changeLanguage(account.language);

+ 52 - 59
web/src/components/Login.js

@@ -8,6 +8,8 @@ import Box from "@mui/material/Box";
 import api from "../app/Api";
 import routes from "./routes";
 import session from "../app/Session";
+import logo from "../img/ntfy2.svg";
+import {NavLink} from "react-router-dom";
 
 const Login = () => {
     const handleSubmit = async (event) => {
@@ -24,68 +26,59 @@ const Login = () => {
     };
 
     return (
-        <>
-            <Box
-                sx={{
-                    marginTop: 8,
-                    display: 'flex',
-                    flexDirection: 'column',
-                    alignItems: 'center',
-                }}
-            >
-                <Avatar sx={{m: 1, bgcolor: 'secondary.main'}}>
-                    <LockOutlinedIcon/>
-                </Avatar>
-                <Typography component="h1" variant="h5">
+        <Box
+            sx={{
+                display: 'flex',
+                flexGrow: 1,
+                justifyContent: 'center',
+                flexDirection: 'column',
+                alignContent: 'center',
+                alignItems: 'center',
+                height: '100vh'
+            }}
+        >
+            <Avatar
+                sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }}
+                src={logo}
+                variant="rounded"
+            />
+            <Typography sx={{ typography: 'h6' }}>
+                Sign in to your ntfy account
+            </Typography>
+            <Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1, maxWidth: 400}}>
+                <TextField
+                    margin="dense"
+                    required
+                    fullWidth
+                    id="username"
+                    label="Username"
+                    name="username"
+                    autoFocus
+                />
+                <TextField
+                    margin="dense"
+                    required
+                    fullWidth
+                    name="password"
+                    label="Password"
+                    type="password"
+                    id="password"
+                    autoComplete="current-password"
+                />
+                <Button
+                    type="submit"
+                    fullWidth
+                    variant="contained"
+                    sx={{mt: 2, mb: 2}}
+                >
                     Sign in
-                </Typography>
-                <Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1}}>
-                    <TextField
-                        margin="normal"
-                        required
-                        fullWidth
-                        id="username"
-                        label="Username"
-                        name="username"
-                        autoFocus
-                    />
-                    <TextField
-                        margin="normal"
-                        required
-                        fullWidth
-                        name="password"
-                        label="Password"
-                        type="password"
-                        id="password"
-                        autoComplete="current-password"
-                    />
-                    <FormControlLabel
-                        control={<Checkbox value="remember" color="primary"/>}
-                        label="Remember me"
-                    />
-                    <Button
-                        type="submit"
-                        fullWidth
-                        variant="contained"
-                        sx={{mt: 3, mb: 2}}
-                    >
-                        Sign In
-                    </Button>
-                    <Grid container>
-                        <Grid item xs>
-                            <Link href="#" variant="body2">
-                                Forgot password?
-                            </Link>
-                        </Grid>
-                        <Grid item>
-                            <Link to={routes.signup} variant="body2">
-                                {"Don't have an account? Sign Up"}
-                            </Link>
-                        </Grid>
-                    </Grid>
+                </Button>
+                <Box sx={{width: "100%"}}>
+                    <NavLink to="#" variant="body1" sx={{float: "left"}}>Reset password</NavLink>
+                    <div style={{float: "right"}}><NavLink to={routes.signup} variant="body1">Sign Up</NavLink></div>
                 </Box>
             </Box>
-        </>
+        </Box>
     );
 }
 

+ 4 - 4
web/src/components/Preferences.js

@@ -73,7 +73,7 @@ const Sound = () => {
     const handleChange = async (ev) => {
         await prefs.setSound(ev.target.value);
         if (session.exists()) {
-            await api.updateUserAccount("http://localhost:2586", session.token(), {
+            await api.updateAccountSettings("http://localhost:2586", session.token(), {
                 notification: {
                     sound: ev.target.value
                 }
@@ -113,7 +113,7 @@ const MinPriority = () => {
     const handleChange = async (ev) => {
         await prefs.setMinPriority(ev.target.value);
         if (session.exists()) {
-            await api.updateUserAccount("http://localhost:2586", session.token(), {
+            await api.updateAccountSettings("http://localhost:2586", session.token(), {
                 notification: {
                     min_priority: ev.target.value
                 }
@@ -163,7 +163,7 @@ const DeleteAfter = () => {
     const handleChange = async (ev) => {
         await prefs.setDeleteAfter(ev.target.value);
         if (session.exists()) {
-            await api.updateUserAccount("http://localhost:2586", session.token(), {
+            await api.updateAccountSettings("http://localhost:2586", session.token(), {
                 notification: {
                     delete_after: ev.target.value
                 }
@@ -467,7 +467,7 @@ const Language = () => {
     const handleChange = async (ev) => {
         await i18n.changeLanguage(ev.target.value);
         if (session.exists()) {
-            await api.updateUserAccount("http://localhost:2586", session.token(), {
+            await api.updateAccountSettings("http://localhost:2586", session.token(), {
                 language: ev.target.value
             });
         }

+ 73 - 69
web/src/components/Signup.js

@@ -1,24 +1,27 @@
 import * as React from 'react';
-import {Avatar, Checkbox, FormControlLabel, Grid, Link, Stack} from "@mui/material";
-import Typography from "@mui/material/Typography";
-import Container from "@mui/material/Container";
-import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
+import {Avatar, Link} from "@mui/material";
 import TextField from "@mui/material/TextField";
 import Button from "@mui/material/Button";
 import Box from "@mui/material/Box";
 import api from "../app/Api";
-import {useNavigate} from "react-router-dom";
 import routes from "./routes";
 import session from "../app/Session";
+import logo from "../img/ntfy2.svg";
+import Typography from "@mui/material/Typography";
+import {NavLink} from "react-router-dom";
 
 const Signup = () => {
     const handleSubmit = async (event) => {
         event.preventDefault();
         const data = new FormData(event.currentTarget);
+        const username = data.get('username');
+        const password = data.get('password');
         const user = {
-            username: data.get('username'),
-            password: data.get('password'),
-        }
+            username: username,
+            password: password
+        }; // FIXME omg so awful
+
+        await api.createAccount("http://localhost:2586"/*window.location.origin*/, username, password);
         const token = await api.login("http://localhost:2586"/*window.location.origin*/, user);
         console.log(`[Api] User auth for user ${user.username} successful, token is ${token}`);
         session.store(user.username, token);
@@ -26,68 +29,69 @@ const Signup = () => {
     };
 
     return (
-        <>
-            <Box
-                sx={{
-                    marginTop: 8,
-                    display: 'flex',
-                    flexDirection: 'column',
-                    alignItems: 'center',
-                }}
-            >
-                <Avatar sx={{m: 1, bgcolor: 'secondary.main'}}>
-                    <LockOutlinedIcon/>
-                </Avatar>
-                <Typography component="h1" variant="h5">
-                    Sign in
-                </Typography>
-                <Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1}}>
-                    <TextField
-                        margin="normal"
-                        required
-                        fullWidth
-                        id="username"
-                        label="Username"
-                        name="username"
-                        autoFocus
-                    />
-                    <TextField
-                        margin="normal"
-                        required
-                        fullWidth
-                        name="password"
-                        label="Password"
-                        type="password"
-                        id="password"
-                        autoComplete="current-password"
-                    />
-                    <FormControlLabel
-                        control={<Checkbox value="remember" color="primary"/>}
-                        label="Remember me"
-                    />
-                    <Button
-                        type="submit"
-                        fullWidth
-                        variant="contained"
-                        sx={{mt: 3, mb: 2}}
-                    >
-                        Sign up
-                    </Button>
-                    <Grid container>
-                        <Grid item xs>
-                            <Link href="#" variant="body2">
-                                Forgot password?
-                            </Link>
-                        </Grid>
-                        <Grid item>
-                            <Link to={routes.signup} variant="body2">
-                                {"Don't have an account? Sign Up"}
-                            </Link>
-                        </Grid>
-                    </Grid>
-                </Box>
+        <Box
+            sx={{
+                display: 'flex',
+                flexGrow: 1,
+                justifyContent: 'center',
+                flexDirection: 'column',
+                alignContent: 'center',
+                alignItems: 'center',
+                height: '100vh'
+            }}
+        >
+            <Avatar
+                sx={{ m: 2, width: 64, height: 64, borderRadius: 3 }}
+                src={logo}
+                variant="rounded"
+            />
+            <Typography sx={{ typography: 'h6' }}>
+                Create a ntfy account
+            </Typography>
+            <Box component="form" onSubmit={handleSubmit} noValidate sx={{mt: 1, maxWidth: 400}}>
+                <TextField
+                    margin="dense"
+                    required
+                    fullWidth
+                    id="username"
+                    label="Username"
+                    name="username"
+                    autoFocus
+                />
+                <TextField
+                    margin="dense"
+                    required
+                    fullWidth
+                    name="password"
+                    label="Password"
+                    type="password"
+                    id="password"
+                    autoComplete="current-password"
+                />
+                <TextField
+                    margin="dense"
+                    required
+                    fullWidth
+                    name="confirm-password"
+                    label="Confirm password"
+                    type="password"
+                    id="confirm-password"
+                />
+                <Button
+                    type="submit"
+                    fullWidth
+                    variant="contained"
+                    sx={{mt: 2, mb: 2}}
+                >
+                    Sign up
+                </Button>
             </Box>
-        </>
+            <Typography sx={{mb: 4}}>
+                <NavLink to={routes.login} variant="body1">
+                    Already have an account? Sign in
+                </NavLink>
+            </Typography>
+        </Box>
     );
 }
 

+ 1 - 1
web/src/components/SiteLayout.js

@@ -14,7 +14,7 @@ const SiteLayout = (props) => {
                         <li><NavLink to={routes.home} activeStyle>Features</NavLink></li>
                         <li><NavLink to={routes.pricing} activeStyle>Pricing</NavLink></li>
                         <li><NavLink to="/docs" reloadDocument={true} activeStyle>Docs</NavLink></li>
-                        {session.exists() && <li><NavLink to={routes.signup} activeStyle>Sign up</NavLink></li>}
+                        {!session.exists() && <li><NavLink to={routes.signup} activeStyle>Sign up</NavLink></li>}
                         {!session.exists() && <li><NavLink to={routes.login} activeStyle>Login</NavLink></li>}
                         <li><NavLink to={routes.app} activeStyle>Open app</NavLink></li>
                     </ol>

+ 1 - 1
web/src/components/SubscribeDialog.js

@@ -28,7 +28,7 @@ const SubscribeDialog = (props) => {
         const actualBaseUrl = (baseUrl) ? baseUrl : window.location.origin;
         const subscription = await subscriptionManager.add(actualBaseUrl, topic);
         if (session.exists()) {
-            const remoteSubscription = await api.userSubscriptionAdd("http://localhost:2586", session.token(), {
+            const remoteSubscription = await api.addAccountSubscription("http://localhost:2586", session.token(), {
                 base_url: actualBaseUrl,
                 topic: topic
             });

+ 1 - 1
web/src/components/hooks.js

@@ -64,7 +64,7 @@ export const useAutoSubscribe = (subscriptions, selected) => {
             (async () => {
                 const subscription = await subscriptionManager.add(baseUrl, params.topic);
                 if (session.exists()) {
-                    const remoteSubscription = await api.userSubscriptionAdd("http://localhost:2586", session.token(), {
+                    const remoteSubscription = await api.addAccountSubscription("http://localhost:2586", session.token(), {
                         base_url: baseUrl,
                         topic: params.topic
                     });

Разница между файлами не показана из-за своего большого размера
+ 0 - 255
web/src/img/ntfy2.svg


Некоторые файлы не были показаны из-за большого количества измененных файлов