let user change password

Massimo Melina committed Dec 26, 2021 at 19:08 UTC 3325bcd207271fced0a7994a2be8d78bc259cabf
9 files changed +86 -34
frontend/src/Head.ts
+31 -13
@@ -6,7 +6,8 @@ import { formatBytes, hIcon, prefix } from './misc'
6 import { Spinner } from './components'
7 import { state, useSnapState } from './state'
8 import { useDebounce } from 'use-debounce'
9 -import { closeDialog, newDialog, promptDialog } from './dialog'
9 +import { alertDialog, closeDialog, newDialog, promptDialog } from './dialog'
10 +import { apiCall } from './api'
11
12 export function Head() {
13 return h('header', {},
@@ -82,18 +83,7 @@ function LoginButton() {
83 icon: 'user',
84 label: snap.username,
85 onClick(){
85 - newDialog({
86 - content: ()=> h('div',{ id:'user-panel' },
87 - h('div',{}, 'User: '+snap.username),
88 - h(MenuButton,{
89 - icon: 'logout',
90 - label: 'Logout',
91 - onClick(){
92 - logout().then(closeDialog)
93 - }
94 - })
95 - )
96 - })
86 + newDialog({ content: UserPanel })
87 },
88 } : {
89 icon: 'login',
@@ -108,6 +98,34 @@ function LoginButton() {
98 })
99 }
100
101 +function UserPanel() {
102 + const snap = useSnapState()
103 + return h('div',{ id:'user-panel' },
104 + h('div',{}, 'User: '+snap.username),
105 + h(MenuButton,{
106 + icon: 'key',
107 + label: 'Change password',
108 + async onClick(){
109 + const pwd = await promptDialog('Enter new password', { type:'password' })
110 + if (!pwd) return
111 + const check = await promptDialog('RE-enter new password', { type:'password' })
112 + if (!check) return
113 + if (check !== pwd)
114 + return alertDialog('The second password you entered did not match the first. Procedure aborted.', 'warning')
115 + await apiCall('change_pwd', { newPassword: pwd })
116 + return alertDialog('Password changed')
117 + }
118 + }),
119 + h(MenuButton,{
120 + icon: 'logout',
121 + label: 'Logout',
122 + onClick(){
123 + logout().then(closeDialog)
124 + }
125 + })
126 + )
127 +}
128 +
129 function FolderStats() {
130 const { list, loading } = useContext(ListContext)
131 const stats = useMemo(() =>{
frontend/src/api.ts
+5 -1
@@ -10,12 +10,16 @@ export function apiCall(cmd: string, params?: object) : Promise<any> {
10 headers: { 'content-type': 'application/json' },
11 body: params && JSON.stringify(params),
12 }).then(res => {
13 + stop()
14 if (res.ok)
15 return res.json()
16 const msg = 'Failed API ' + cmd
17 console.warn(msg + (params ? ' ' + JSON.stringify(params) : ''))
18 throw new ApiError(res.status, msg)
18 - }).finally(stop)
19 + }, err => {
20 + stop()
21 + throw err
22 + })
23 }
24
25 export class ApiError extends Error {
frontend/src/dialog.ts
+10 -2
@@ -8,7 +8,8 @@ interface DialogOptions {
8 onClose?: (v?:any)=> any,
9 className?: string,
10 icon?: string | FunctionComponent,
11 - closableContent?: string | ReactNode
11 + closableContent?: string | ReactNode,
12 + reserveClosing?: true
13 }
14
15 const dialogs = proxy<DialogOptions[]>([])
@@ -57,7 +58,14 @@ export function newDialog(options: DialogOptions) {
58 }
59
60 export function closeDialog(v?:any) {
60 - dialogs.pop()?.onClose?.(v)
61 + let i = dialogs.length
62 + while (i--) {
63 + const d = dialogs[i]
64 + if (d.reserveClosing)
65 + continue
66 + dialogs.splice(i,1)
67 + d.onClose?.(v)
68 + }
69 }
70
71 interface PromptOptions { def?:string, type?:string }
frontend/src/index.scss
+4 -4
@@ -138,10 +138,6 @@ ul.dir {
138 margin: 0.1em;
139 font-size: 1.1em;
140 }
141 - & button label {
142 - cursor: inherit;
143 - margin-left: 0.8em;
144 - }
141 }
142 #searched {
143 margin: .2em;
@@ -152,6 +148,10 @@ ul.dir {
148 gap: 1em;
149 }
150
151 +button label {
152 + cursor: inherit;
153 + margin-left: 0.5em;
154 +}
155 .dialog.working {
156 background: none;
157 font-size: 5em;
frontend/src/misc.ts
+1
@@ -62,6 +62,7 @@ export function working() {
62 return newDialog({
63 closable: false,
64 content: Spinner,
65 + reserveClosing: true,
66 className: 'working',
67 })
68 }
src/apis.ts
+8 -2
@@ -2,7 +2,7 @@ import Koa from 'koa'
2 import { vfs, VfsNode, walkNode } from './vfs'
3 import { stat } from 'fs/promises'
4 import _ from 'lodash'
5 -import { getCurrentUserExpanded, verifyLogin } from './perm'
5 +import { getCurrentUsername, getCurrentUsernameExpanded, updateAccount, verifyLogin } from './perm'
6 import { sessions } from './sessions'
7 import createSSE from './sse'
8 import { basename } from 'path'
@@ -51,7 +51,7 @@ export const frontEndApis: ApiHandlers = {
51 limit = Number(limit)
52 const re = new RegExp(_.escapeRegExp(search),'i')
53 const match = (s?:string) => !s || !search || re.test(s)
54 - const who = await getCurrentUserExpanded(ctx) // cache value
54 + const who = await getCurrentUsernameExpanded(ctx) // cache value
55 const walker = walkNode(node, who, search ? Infinity : 0)
56 const sseSrv = sse ? createSSE(ctx) : null
57 const res = produceEntries()
@@ -129,6 +129,12 @@ export const frontEndApis: ApiHandlers = {
129 return sess
130 },
131
132 + async change_pwd({ newPassword }, ctx) {
133 + await updateAccount(await getCurrentUsername(ctx), account => {
134 + account.password = newPassword
135 + })
136 + return true
137 + }
138 }
139
140 async function nodeToDirEntry(node: VfsNode): Promise<DirEntry | null> {
src/perm.ts
+25 -8
@@ -11,23 +11,23 @@ import Koa from 'koa'
11
12 const PATH = argv.accounts || 'accounts.yaml'
13
14 -interface UserDetails {
14 +interface Account {
15 user: string, // we'll have user in it, so we don't need to pass it separately
16 password?: string
17 hashedPassword?: string
18 belongs?: string[]
19 }
20 -interface Accounts { [username:string]: UserDetails }
20 +interface Accounts { [username:string]: Account }
21
22 let accounts: Accounts = {}
23
24 -export async function getCurrentUser(ctx: Koa.Context) {
24 +export async function getCurrentUsername(ctx: Koa.Context) {
25 const id = ctx.cookies.get(SESSION_COOKIE)
26 return id && sessions.get(id)?.user || ''
27 }
28
29 -export async function getCurrentUserExpanded(ctx: Koa.Context) {
30 - const who = await getCurrentUser(ctx)
29 +export async function getCurrentUsernameExpanded(ctx: Koa.Context) {
30 + const who = await getCurrentUsername(ctx)
31 if (!who)
32 return []
33 const ret = [who]
@@ -46,8 +46,25 @@ export async function verifyLogin(user:string, password: string) {
46 return h && verifyPassword(h, password)
47 }
48
49 -export function getAccount(user:string) : UserDetails {
50 - return accounts[user]
49 +export function getAccount(username:string) : Account {
50 + return accounts[username]
51 +}
52 +
53 +type Changer = (account:Account)=> void | Promise<void>
54 +export async function updateAccount(username: string, changer:Changer) {
55 + const account = getAccount(username)
56 + await changer(account)
57 + if (account.password) {
58 + account.hashedPassword = await hashPassword(account.password)
59 + delete account.password
60 + }
61 + saveAccountsAsap()
62 +}
63 +
64 +const saveAccountsAsap = _.debounce(saveAccounts)
65 +
66 +function saveAccounts() {
67 + return fs.writeFile(PATH, yaml.stringify({ accounts }))
68 }
69
70 let doing = false
@@ -87,7 +104,7 @@ async function load() {
104 }
105 }))
106 if (changed)
90 - await fs.writeFile(PATH, yaml.stringify(res))
107 + await saveAccountsAsap()
108 }
109 finally { doing = false }
110 }
src/vfs.ts
+2 -2
@@ -5,7 +5,7 @@ import { FSWatcher, watch } from 'fs'
5 import { dirname, basename } from 'path'
6 import { isMatch } from 'micromatch'
7 import { complySlashes, enforceFinal, prefix, readFileBusy } from './misc'
8 -import { getCurrentUserExpanded } from './perm'
8 +import { getCurrentUsernameExpanded } from './perm'
9 import Koa from 'koa'
10 import glob from 'fast-glob'
11 import _ from 'lodash'
@@ -83,7 +83,7 @@ export class Vfs {
83 }
84
85 async urlToNode(url: string, ctx: Koa.Context) : Promise<VfsNode | undefined> {
86 - const users = await getCurrentUserExpanded(ctx)
86 + const users = await getCurrentUsernameExpanded(ctx)
87 let run = this.root
88 const rest = url.split('/').filter(Boolean).map(decodeURIComponent)
89 if (forbidden(run, users)) return
todo.md
-2
@@ -1,6 +1,5 @@
1 # To do
2 - file sorting
3 -- let user change password (need dialogs)
3 - folders before?
4 - upload
5 - log file
@@ -14,7 +13,6 @@
13 - user.redirect
14 - config: bans
15 - config: min disk space
17 -- frontend: dialogs
16 - frontend: don't depend on cdn
17 - webdav?
18 - vfs: ability to remove/hide/rename files deep in a source