Token login
This commit is contained in:
		
							parent
							
								
									35657a7bbd
								
							
						
					
					
						commit
						8dcb4be8a8
					
				
					 11 changed files with 94 additions and 26 deletions
				
			
		|  | @ -14,8 +14,8 @@ type Auther interface { | ||||||
| 	Authenticate(username, password string) (*User, error) | 	Authenticate(username, password string) (*User, error) | ||||||
| 
 | 
 | ||||||
| 	AuthenticateToken(token string) (*User, error) | 	AuthenticateToken(token string) (*User, error) | ||||||
| 
 | 	CreateToken(user *User) (string, error) | ||||||
| 	GenerateToken(user *User) (string, error) | 	RemoveToken(user *User) error | ||||||
| 
 | 
 | ||||||
| 	// Authorize returns nil if the given user has access to the given topic using the desired | 	// 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. | 	// permission. The user param may be nil to signal an anonymous user. | ||||||
|  | @ -62,6 +62,7 @@ type Manager interface { | ||||||
| type User struct { | type User struct { | ||||||
| 	Name     string | 	Name     string | ||||||
| 	Hash     string // password hash (bcrypt) | 	Hash     string // password hash (bcrypt) | ||||||
|  | 	Token    string // Only set if token was used to log in | ||||||
| 	Role     Role | 	Role     Role | ||||||
| 	Grants   []Grant | 	Grants   []Grant | ||||||
| 	Language string | 	Language string | ||||||
|  |  | ||||||
|  | @ -102,6 +102,7 @@ const ( | ||||||
| 	deleteTopicAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) AND topic = ?` | 	deleteTopicAccessQuery = `DELETE FROM user_access WHERE user_id = (SELECT id FROM user WHERE user = ?) AND topic = ?` | ||||||
| 
 | 
 | ||||||
| 	insertTokenQuery = `INSERT INTO user_token (user_id, token, expires) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?)` | 	insertTokenQuery = `INSERT INTO user_token (user_id, token, expires) VALUES ((SELECT id FROM user WHERE user = ?), ?, ?)` | ||||||
|  | 	deleteTokenQuery = `DELETE FROM user_token WHERE user_id = (SELECT id FROM user WHERE user = ?) AND token = ?` | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| // Schema management queries | // Schema management queries | ||||||
|  | @ -138,7 +139,7 @@ func NewSQLiteAuth(filename string, defaultRead, defaultWrite bool) (*SQLiteAuth | ||||||
| 	}, nil | 	}, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // AuthenticateUser checks username and password and returns a user if correct. The method | // 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 | // returns in constant-ish time, regardless of whether the user exists or the password is | ||||||
| // correct or incorrect. | // correct or incorrect. | ||||||
| func (a *SQLiteAuth) Authenticate(username, password string) (*User, error) { | func (a *SQLiteAuth) Authenticate(username, password string) (*User, error) { | ||||||
|  | @ -162,10 +163,11 @@ func (a *SQLiteAuth) AuthenticateToken(token string) (*User, error) { | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, ErrUnauthenticated | 		return nil, ErrUnauthenticated | ||||||
| 	} | 	} | ||||||
|  | 	user.Token = token | ||||||
| 	return user, nil | 	return user, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (a *SQLiteAuth) GenerateToken(user *User) (string, error) { | func (a *SQLiteAuth) CreateToken(user *User) (string, error) { | ||||||
| 	token := util.RandomString(tokenLength) | 	token := util.RandomString(tokenLength) | ||||||
| 	expires := 1 // FIXME | 	expires := 1 // FIXME | ||||||
| 	if _, err := a.db.Exec(insertTokenQuery, user.Name, token, expires); err != nil { | 	if _, err := a.db.Exec(insertTokenQuery, user.Name, token, expires); err != nil { | ||||||
|  | @ -174,6 +176,16 @@ func (a *SQLiteAuth) GenerateToken(user *User) (string, error) { | ||||||
| 	return token, nil | 	return token, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (a *SQLiteAuth) RemoveToken(user *User) error { | ||||||
|  | 	if user.Token == "" { | ||||||
|  | 		return ErrUnauthorized | ||||||
|  | 	} | ||||||
|  | 	if _, err := a.db.Exec(deleteTokenQuery, user.Name, user.Token); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // Authorize returns nil if the given user has access to the given topic using the desired | // 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. | // 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 *SQLiteAuth) Authorize(user *User, topic string, perm Permission) error { | ||||||
|  |  | ||||||
|  | @ -34,6 +34,17 @@ import ( | ||||||
| 	"heckel.io/ntfy/util" | 	"heckel.io/ntfy/util" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | /* | ||||||
|  | 	TODO | ||||||
|  | 		expire tokens | ||||||
|  | 		auto-refresh tokens from UI | ||||||
|  | 		pricing page | ||||||
|  | 		home page | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | */ | ||||||
|  | 
 | ||||||
| // Server is the main server, providing the UI and API for ntfy | // Server is the main server, providing the UI and API for ntfy | ||||||
| type Server struct { | type Server struct { | ||||||
| 	config            *Config | 	config            *Config | ||||||
|  | @ -71,7 +82,7 @@ var ( | ||||||
| 
 | 
 | ||||||
| 	webConfigPath    = "/config.js" | 	webConfigPath    = "/config.js" | ||||||
| 	userStatsPath    = "/user/stats" // FIXME get rid of this in favor of /user/account | 	userStatsPath    = "/user/stats" // FIXME get rid of this in favor of /user/account | ||||||
| 	userAuthPath     = "/user/auth" | 	userTokenPath    = "/user/token" | ||||||
| 	userAccountPath  = "/user/account" | 	userAccountPath  = "/user/account" | ||||||
| 	matrixPushPath   = "/_matrix/push/v1/notify" | 	matrixPushPath   = "/_matrix/push/v1/notify" | ||||||
| 	staticRegex      = regexp.MustCompile(`^/static/.+`) | 	staticRegex      = regexp.MustCompile(`^/static/.+`) | ||||||
|  | @ -306,8 +317,10 @@ func (s *Server) handleInternal(w http.ResponseWriter, r *http.Request, v *visit | ||||||
| 		return s.ensureWebEnabled(s.handleWebConfig)(w, r, v) | 		return s.ensureWebEnabled(s.handleWebConfig)(w, r, v) | ||||||
| 	} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath { | 	} else if r.Method == http.MethodGet && r.URL.Path == userStatsPath { | ||||||
| 		return s.handleUserStats(w, r, v) | 		return s.handleUserStats(w, r, v) | ||||||
| 	} else if r.Method == http.MethodGet && r.URL.Path == userAuthPath { | 	} else if r.Method == http.MethodGet && r.URL.Path == userTokenPath { | ||||||
| 		return s.handleUserAuth(w, r, v) | 		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 { | 	} else if r.Method == http.MethodGet && r.URL.Path == userAccountPath { | ||||||
| 		return s.handleUserAccount(w, r, v) | 		return s.handleUserAccount(w, r, v) | ||||||
| 	} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath { | 	} else if r.Method == http.MethodGet && r.URL.Path == matrixPushPath { | ||||||
|  | @ -408,16 +421,16 @@ type tokenAuthResponse struct { | ||||||
| 	Token string `json:"token"` | 	Token string `json:"token"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *Server) handleUserAuth(w http.ResponseWriter, r *http.Request, v *visitor) error { | func (s *Server) handleUserTokenCreate(w http.ResponseWriter, r *http.Request, v *visitor) error { | ||||||
| 	// TODO rate limit | 	// TODO rate limit | ||||||
| 	if v.user == nil { | 	if v.user == nil { | ||||||
| 		return errHTTPUnauthorized | 		return errHTTPUnauthorized | ||||||
| 	} | 	} | ||||||
| 	token, err := s.auth.GenerateToken(v.user) | 	token, err := s.auth.CreateToken(v.user) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 	w.Header().Set("Content-Type", "text/json") | 	w.Header().Set("Content-Type", "application/json") | ||||||
| 	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this | 	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this | ||||||
| 	response := &tokenAuthResponse{ | 	response := &tokenAuthResponse{ | ||||||
| 		Token: token, | 		Token: token, | ||||||
|  | @ -428,6 +441,18 @@ func (s *Server) handleUserAuth(w http.ResponseWriter, r *http.Request, v *visit | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (s *Server) handleUserTokenDelete(w http.ResponseWriter, r *http.Request, v *visitor) error { | ||||||
|  | 	// TODO rate limit | ||||||
|  | 	if v.user == nil || v.user.Token == "" { | ||||||
|  | 		return errHTTPUnauthorized | ||||||
|  | 	} | ||||||
|  | 	if err := s.auth.RemoveToken(v.user); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
| type userSubscriptionResponse struct { | type userSubscriptionResponse struct { | ||||||
| 	BaseURL string `json:"base_url"` | 	BaseURL string `json:"base_url"` | ||||||
| 	Topic   string `json:"topic"` | 	Topic   string `json:"topic"` | ||||||
|  | @ -454,7 +479,7 @@ type userAccountResponse struct { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *Server) handleUserAccount(w http.ResponseWriter, r *http.Request, v *visitor) error { | func (s *Server) handleUserAccount(w http.ResponseWriter, r *http.Request, v *visitor) error { | ||||||
| 	w.Header().Set("Content-Type", "text/json") | 	w.Header().Set("Content-Type", "application/json") | ||||||
| 	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this | 	w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this | ||||||
| 	response := &userAccountResponse{} | 	response := &userAccountResponse{} | ||||||
| 	if v.user != nil { | 	if v.user != nil { | ||||||
|  | @ -1136,7 +1161,7 @@ func parseSince(r *http.Request, poll bool) (sinceMarker, error) { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request, _ *visitor) error { | func (s *Server) handleOptions(w http.ResponseWriter, _ *http.Request, _ *visitor) error { | ||||||
| 	w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST") | 	w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, POST, DELETE") | ||||||
| 	w.Header().Set("Access-Control-Allow-Origin", "*")  // CORS, allow cross-origin requests | 	w.Header().Set("Access-Control-Allow-Origin", "*")  // CORS, allow cross-origin requests | ||||||
| 	w.Header().Set("Access-Control-Allow-Headers", "*") // CORS, allow auth via JS // FIXME is this terrible? | 	w.Header().Set("Access-Control-Allow-Headers", "*") // CORS, allow auth via JS // FIXME is this terrible? | ||||||
| 	return nil | 	return nil | ||||||
|  |  | ||||||
|  | @ -7,7 +7,7 @@ import { | ||||||
|     topicUrlJsonPoll, |     topicUrlJsonPoll, | ||||||
|     topicUrlJsonPollWithSince, |     topicUrlJsonPollWithSince, | ||||||
|     userAccountUrl, |     userAccountUrl, | ||||||
|     userAuthUrl, |     userTokenUrl, | ||||||
|     userStatsUrl |     userStatsUrl | ||||||
| } from "./utils"; | } from "./utils"; | ||||||
| import userManager from "./UserManager"; | import userManager from "./UserManager"; | ||||||
|  | @ -119,8 +119,8 @@ class Api { | ||||||
|         throw new Error(`Unexpected server response ${response.status}`); |         throw new Error(`Unexpected server response ${response.status}`); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async userAuth(baseUrl, user) { |     async login(baseUrl, user) { | ||||||
|         const url = userAuthUrl(baseUrl); |         const url = userTokenUrl(baseUrl); | ||||||
|         console.log(`[Api] Checking auth for ${url}`); |         console.log(`[Api] Checking auth for ${url}`); | ||||||
|         const response = await fetch(url, { |         const response = await fetch(url, { | ||||||
|             headers: maybeWithBasicAuth({}, user) |             headers: maybeWithBasicAuth({}, user) | ||||||
|  | @ -135,6 +135,18 @@ class Api { | ||||||
|         return json.token; |         return json.token; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     async logout(baseUrl, token) { | ||||||
|  |         const url = userTokenUrl(baseUrl); | ||||||
|  |         console.log(`[Api] Logging out from ${url} using token ${token}`); | ||||||
|  |         const response = await fetch(url, { | ||||||
|  |             method: "DELETE", | ||||||
|  |             headers: maybeWithBearerAuth({}, token) | ||||||
|  |         }); | ||||||
|  |         if (response.status !== 200) { | ||||||
|  |             throw new Error(`Unexpected server response ${response.status}`); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     async userStats(baseUrl) { |     async userStats(baseUrl) { | ||||||
|         const url = userStatsUrl(baseUrl); |         const url = userStatsUrl(baseUrl); | ||||||
|         console.log(`[Api] Fetching user stats ${url}`); |         console.log(`[Api] Fetching user stats ${url}`); | ||||||
|  |  | ||||||
|  | @ -9,6 +9,10 @@ class Session { | ||||||
|         localStorage.removeItem("token"); |         localStorage.removeItem("token"); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     exists() { | ||||||
|  |         return this.username() && this.token(); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     username() { |     username() { | ||||||
|         return localStorage.getItem("user"); |         return localStorage.getItem("user"); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -19,7 +19,7 @@ export const topicUrlJsonPollWithSince = (baseUrl, topic, since) => `${topicUrlJ | ||||||
| export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`; | export const topicUrlAuth = (baseUrl, topic) => `${topicUrl(baseUrl, topic)}/auth`; | ||||||
| export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic)); | export const topicShortUrl = (baseUrl, topic) => shortUrl(topicUrl(baseUrl, topic)); | ||||||
| export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`; | export const userStatsUrl = (baseUrl) => `${baseUrl}/user/stats`; | ||||||
| export const userAuthUrl = (baseUrl) => `${baseUrl}/user/auth`; | export const userTokenUrl = (baseUrl) => `${baseUrl}/user/token`; | ||||||
| export const userAccountUrl = (baseUrl) => `${baseUrl}/user/account`; | export const userAccountUrl = (baseUrl) => `${baseUrl}/user/account`; | ||||||
| export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); | export const shortUrl = (url) => url.replaceAll(/https?:\/\//g, ""); | ||||||
| export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; | export const expandUrl = (url) => [`https://${url}`, `http://${url}`]; | ||||||
|  |  | ||||||
|  | @ -246,7 +246,7 @@ const ProfileIcon = (props) => { | ||||||
|     const { t } = useTranslation(); |     const { t } = useTranslation(); | ||||||
|     const [open, setOpen] = useState(false); |     const [open, setOpen] = useState(false); | ||||||
|     const anchorRef = useRef(null); |     const anchorRef = useRef(null); | ||||||
|     const username = session.username(); |     const navigate = useNavigate(); | ||||||
| 
 | 
 | ||||||
|     const handleToggleOpen = () => { |     const handleToggleOpen = () => { | ||||||
|         setOpen((prevOpen) => !prevOpen); |         setOpen((prevOpen) => !prevOpen); | ||||||
|  | @ -272,7 +272,8 @@ const ProfileIcon = (props) => { | ||||||
|         // TODO
 |         // TODO
 | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     const handleLogout = () => { |     const handleLogout = async () => { | ||||||
|  |         await api.logout("http://localhost:2586"/*window.location.origin*/, session.token()); | ||||||
|         session.reset(); |         session.reset(); | ||||||
|         window.location.href = routes.app; |         window.location.href = routes.app; | ||||||
|     }; |     }; | ||||||
|  | @ -288,15 +289,15 @@ const ProfileIcon = (props) => { | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|         <> |         <> | ||||||
|             {username && |             {session.exists() && | ||||||
|                 <IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen} sx={{marginRight: 0}} aria-label={t("xxxxxxx")}> |                 <IconButton color="inherit" size="large" edge="end" ref={anchorRef} onClick={handleToggleOpen} sx={{marginRight: 0}} aria-label={t("xxxxxxx")}> | ||||||
|                     <AccountCircleIcon/> |                     <AccountCircleIcon/> | ||||||
|                 </IconButton> |                 </IconButton> | ||||||
|             } |             } | ||||||
|             {!username && |             {!session.exists() && | ||||||
|                 <> |                 <> | ||||||
|                     <Button>Sign in</Button> |                     <Button color="inherit" variant="outlined" onClick={() => navigate(routes.login)}>Sign in</Button> | ||||||
|                     <Button>Sign up</Button> |                     <Button color="inherit" variant="outlined" onClick={() => navigate(routes.signup)}>Sign up</Button> | ||||||
|                 </> |                 </> | ||||||
|             } |             } | ||||||
|             <Popper |             <Popper | ||||||
|  |  | ||||||
|  | @ -36,7 +36,7 @@ const Login = () => { | ||||||
|             username: data.get('email'), |             username: data.get('email'), | ||||||
|             password: data.get('password'), |             password: data.get('password'), | ||||||
|         } |         } | ||||||
|         const token = await api.userAuth("http://localhost:2586"/*window.location.origin*/, user); |         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}`); |         console.log(`[Api] User auth for user ${user.username} successful, token is ${token}`); | ||||||
|         session.store(user.username, token); |         session.store(user.username, token); | ||||||
|         window.location.href = routes.app; |         window.location.href = routes.app; | ||||||
|  |  | ||||||
|  | @ -84,7 +84,10 @@ const NotificationList = (props) => { | ||||||
|     useEffect(() => { |     useEffect(() => { | ||||||
|         return () => { |         return () => { | ||||||
|             setMaxCount(pageSize); |             setMaxCount(pageSize); | ||||||
|             document.getElementById("main").scrollTo(0, 0); |             const main = document.getElementById("main"); | ||||||
|  |             if (main) { | ||||||
|  |                 main.scrollTo(0, 0); | ||||||
|  |             } | ||||||
|         } |         } | ||||||
|     }, [props.id]); |     }, [props.id]); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -441,6 +441,11 @@ const Language = () => { | ||||||
|     const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" "); |     const title = t("prefs_appearance_language_title") + " " + randomFlags.join(" "); | ||||||
|     const lang = i18n.language ?? "en"; |     const lang = i18n.language ?? "en"; | ||||||
| 
 | 
 | ||||||
|  |     const handleChange = async (ev) => { | ||||||
|  |         await i18n.changeLanguage(ev.target.value); | ||||||
|  |         //api.update
 | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|     // Remember: Flags are not languages. Don't put flags next to the language in the list.
 |     // Remember: Flags are not languages. Don't put flags next to the language in the list.
 | ||||||
|     // Languages names from: https://www.omniglot.com/language/names.htm
 |     // Languages names from: https://www.omniglot.com/language/names.htm
 | ||||||
|     // Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l
 |     // Better: Sidebar in Wikipedia: https://en.wikipedia.org/wiki/Bokm%C3%A5l
 | ||||||
|  | @ -448,7 +453,7 @@ const Language = () => { | ||||||
|     return ( |     return ( | ||||||
|         <Pref labelId={labelId} title={title}> |         <Pref labelId={labelId} title={title}> | ||||||
|             <FormControl fullWidth variant="standard" sx={{ m: 1 }}> |             <FormControl fullWidth variant="standard" sx={{ m: 1 }}> | ||||||
|                 <Select value={lang} onChange={(ev) => i18n.changeLanguage(ev.target.value)} aria-labelledby={labelId}> |                 <Select value={lang} onChange={handleChange} aria-labelledby={labelId}> | ||||||
|                     <MenuItem value="en">English</MenuItem> |                     <MenuItem value="en">English</MenuItem> | ||||||
|                     <MenuItem value="id">Bahasa Indonesia</MenuItem> |                     <MenuItem value="id">Bahasa Indonesia</MenuItem> | ||||||
|                     <MenuItem value="bg">Български</MenuItem> |                     <MenuItem value="bg">Български</MenuItem> | ||||||
|  | @ -474,6 +479,10 @@ const Language = () => { | ||||||
|     ) |     ) | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | const AccessControl = () => { | ||||||
|  |     return <></>; | ||||||
|  | } | ||||||
|  | /* | ||||||
| const AccessControl = () => { | const AccessControl = () => { | ||||||
|     const { t } = useTranslation(); |     const { t } = useTranslation(); | ||||||
|     const [dialogKey, setDialogKey] = useState(0); |     const [dialogKey, setDialogKey] = useState(0); | ||||||
|  | @ -632,6 +641,6 @@ const AccessControlDialog = (props) => { | ||||||
|         </Dialog> |         </Dialog> | ||||||
|     ); |     ); | ||||||
| }; | }; | ||||||
| 
 | */ | ||||||
| 
 | 
 | ||||||
| export default Preferences; | export default Preferences; | ||||||
|  |  | ||||||
|  | @ -4,6 +4,7 @@ import {shortUrl} from "../app/utils"; | ||||||
| const routes = { | const routes = { | ||||||
|     home: "/", |     home: "/", | ||||||
|     login: "/login", |     login: "/login", | ||||||
|  |     signup: "/signup", | ||||||
|     app: config.appRoot, |     app: config.appRoot, | ||||||
|     settings: "/settings", |     settings: "/settings", | ||||||
|     subscription: "/:topic", |     subscription: "/:topic", | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue