Skip to content

Commit 0d44509

Browse files
committed
✨ Provide password protection design #14
1 parent 249634b commit 0d44509

31 files changed

Lines changed: 625 additions & 62 deletions

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,20 @@ docker run -d -p 8888:8888 -v "$(pwd)/data:/app/data" nicejade/wealth-tracker:la
112112

113113
如果您在本地部署,只需打开网址——[http://localhost:8888](http://localhost:8888/) 即可访问。如果在服务器运行,可通过 http://[Server-IP]:8888 来访问,您也可以指定其他端口。
114114

115+
您可以通过设置以下环境变量来配置应用的行为,详情参见[如何开启密码保护?](https://fine.niceshare.site/projects/wealth-tracker/#如何开启密码保护):
116+
117+
- `ALLOW_PASSWORD`: 设置为 `true` 启用密码保护功能;
118+
- `PEPPER_SECRET`: 设置它为用户密码提供更强大的保护;
119+
- `CAN_BE_RESET`: 设置为 `true` 允许重置数据库功能;
120+
121+
```bash
122+
docker run -d -p 8888:8888 \
123+
-e ALLOW_PASSWORD=true \
124+
-e CAN_BE_RESET=true \
125+
-v "$(pwd)/data:/app/data" \
126+
nicejade/wealth-tracker:latest
127+
```
128+
115129
### 采用 [pm2](https://pm2.keymetrics.io/) 部署
116130

117131
PM2 是一个强大的生产环境进程管理器,它不仅支持通过命令行启动应用,还可以使用配置文件(通常名为 `ecosystem.config.js`)来管理复杂的部署场景。为了简化部署流程并确保一致性,本项目已将所有必要的 PM2 配置和启动命令封装到了 `npm` 脚本中:

client/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@
6565
"tailwindcss": "^3.2.7",
6666
"tslib": "^2.5.0",
6767
"typescript": "^4.9.3",
68-
"vite": "^4.5.3",
68+
"vite": "4.5.11",
6969
"vite-plugin-svg-icons": "^2.0.1"
7070
}
7171
}

client/src/App.svelte

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,43 @@
11
<script lang="ts">
2+
import { onMount } from 'svelte'
3+
import { _ } from 'svelte-i18n'
4+
import './lang/i18n'
25
import { Router, createRouter } from '@roxi/routify'
36
import routes from '../.routify/routes.default.js'
47
import Alert from './components/Alert.svelte'
58
import Notice from './components/Notice.svelte'
6-
import './lang/i18n'
9+
import Loading from './components/Loading.svelte'
10+
import FillPassword from './components/FillPassword.svelte'
11+
import { initializeAuth } from './helper/auth'
12+
import { isAuthenticated, isLoading } from './stores'
713
import './assets/styles/app.css'
814
915
routes.children.forEach((element) => {
1016
element.name = element.name.toLowerCase()
1117
})
12-
const router = createRouter({ routes })
18+
19+
onMount(async () => {
20+
await initializeAuth()
21+
})
22+
23+
const router = createRouter({
24+
routes,
25+
})
1326
</script>
1427

1528
<main
1629
id="main"
1730
class="m-auto mx-auto flex h-full w-full max-w-3xl flex-col md:max-w-full md:px-4 lg:max-w-4xl">
18-
<Router {router} />
31+
{#if $isLoading}
32+
<div class="flex h-[100vh] w-full items-center justify-center">
33+
<Loading></Loading>
34+
</div>
35+
{:else}
36+
<FillPassword />
37+
{#if $isAuthenticated}
38+
<Router {router} />
39+
{/if}
40+
{/if}
1941
</main>
2042

2143
<Alert />

client/src/assets/styles/apply.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,5 @@
4040

4141
/* Reusable utility class to apply focus-visible ring styles (use with buttons, links, etc.) */
4242
.focus-visible-ring {
43-
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2;
43+
@apply focus-visible:border-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2;
4444
}

client/src/components/ChartWidget/TableWidget.svelte

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,15 @@
1313
import { _ } from 'svelte-i18n'
1414
import Caption from '../Caption.svelte'
1515
import confetti from 'canvas-confetti'
16-
import { randomInRange, fetchExchangeRates, convertCurrency } from './../../helper/utils'
17-
import { exchangeRates, language, targetCurrencyCode, targetCurrencyName } from '../../stores'
1816
import { SUPPORTED_CURRENCIES } from './../../helper/constant'
17+
import { randomInRange, fetchExchangeRates, convertCurrency } from './../../helper/utils'
18+
import {
19+
exchangeRates,
20+
language,
21+
targetCurrencyCode,
22+
targetCurrencyName,
23+
isResettable,
24+
} from '../../stores'
1925
2026
$: if ($targetCurrencyCode || $language) {
2127
targetCurrencyName.set($_(`currencys.${$targetCurrencyCode}`) || $targetCurrencyCode)
@@ -150,7 +156,12 @@
150156
</Button>
151157
</TableBodyCell>
152158
<TableBodyCell>
153-
<Button size="sm" outline class="border-none focus:ring-0" on:click={onResetClick}>
159+
<Button
160+
size="sm"
161+
outline
162+
disabled={!$isResettable}
163+
class="border-none focus:ring-0"
164+
on:click={onResetClick}>
154165
<span class="text-mark hover:text-brand font-bold">{$_('reset')}</span>
155166
</Button>
156167
</TableBodyCell>
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<script lang="ts">
2+
// @ts-ignore
3+
import { onDestroy } from 'svelte'
4+
// @ts-ignore
5+
import { _ } from 'svelte-i18n'
6+
import { Spinner } from 'flowbite-svelte'
7+
import { hashPassword } from './../helper/auth'
8+
import { verifyPassword } from './../helper/apis'
9+
import { alert, isAuthenticated, isLoading } from './../stores'
10+
import { sleep } from '../helper/utils'
11+
12+
let password: string = ''
13+
let isSubmitting: boolean = false
14+
let failedAttempts: number = 0
15+
const ATTEMPT_LIMIT: number = 3
16+
17+
const handleSubmit = async () => {
18+
if (isSubmitting) return
19+
isSubmitting = true
20+
21+
if (failedAttempts >= ATTEMPT_LIMIT) {
22+
await sleep(failedAttempts * 1000)
23+
}
24+
25+
try {
26+
const hashedPassword = await hashPassword(password)
27+
await verifyPassword(hashedPassword)
28+
isAuthenticated.set(true)
29+
failedAttempts = 0
30+
} catch (error) {
31+
failedAttempts += 1
32+
alert.set(error?.message)
33+
password = ''
34+
} finally {
35+
isSubmitting = false
36+
}
37+
}
38+
39+
onDestroy(() => {
40+
password = ''
41+
isSubmitting = false
42+
})
43+
</script>
44+
45+
{#if !$isAuthenticated && !$isLoading}
46+
<div class="fixed inset-0 flex items-center justify-center">
47+
<div class="flex flex-col space-y-8">
48+
<form class="relative flex flex-row items-center" on:submit|preventDefault={handleSubmit}>
49+
<input type="text" name="username" autocomplete="username" class="hidden" value="wealth" />
50+
<input
51+
id="password"
52+
type="password"
53+
bind:value={password}
54+
required
55+
autocomplete="new-password"
56+
class="custom-input ml-0"
57+
placeholder={$_('enterPassword')} />
58+
<button type="submit" disabled={isSubmitting} class="regular-btn ml-6">
59+
{#if isSubmitting}
60+
<Spinner color="blue" size="5" />
61+
{/if}
62+
{$_('confirm')}
63+
</button>
64+
</form>
65+
</div>
66+
</div>
67+
{/if}

client/src/components/Loading.svelte

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
</script>
44

55
<div class="flex w-full items-center justify-center">
6-
<span class="text-brand md:text-sm">{text}</span>
6+
{#if text}
7+
<span class="text-brand md:text-sm">{text}</span>
8+
{/if}
79
<div class="balls mx-2">
810
<div />
911
<div />

client/src/components/Modal/Setting.svelte

Lines changed: 74 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,28 @@
11
<script lang="ts">
22
import { onMount, onDestroy, createEventDispatcher } from 'svelte'
3+
import { Toggle } from 'flowbite-svelte'
34
import { Modal } from 'flowbite'
45
import { _ } from 'svelte-i18n'
56
import SvgIcon from '../SvgIcon.svelte'
67
import { EXCHANGE_RATE_API_KEY, BITCOIN_API_KEY } from './../../helper/constant'
78
import { fetchExchangeRates } from './../../helper/utils'
9+
import { hashPassword } from './../../helper/auth'
10+
import { setPassword } from './../../helper/apis'
11+
import { alert, isPasswordAllowed } from './../../stores'
812
import type { ModalOptions } from 'flowbite'
913
1014
const dispatch = createEventDispatcher()
11-
const MODAL_KEY = 'setting-modal'
12-
let modal = null
13-
let loading = false
14-
let error = ''
15-
let rateApiKey = localStorage.getItem(EXCHANGE_RATE_API_KEY) || ''
16-
let bitcoinApiKey = localStorage.getItem(BITCOIN_API_KEY) || ''
15+
let modal: any = null
16+
const MODAL_KEY: string = 'setting-modal'
17+
let loading: boolean = false
18+
let error: string = ''
19+
let rateApiKey: string = localStorage.getItem(EXCHANGE_RATE_API_KEY) || ''
20+
let bitcoinApiKey: string = localStorage.getItem(BITCOIN_API_KEY) || ''
21+
let password: string = ''
22+
let confirmPassword: string = ''
1723
18-
$: {
24+
$: if (error) {
25+
alert.set(error)
1926
const isValidRate = !rateApiKey || isValidRateApiKey(rateApiKey)
2027
const isValidBitcoin = !bitcoinApiKey || isValidBitcoinApiKey(bitcoinApiKey)
2128
if (!isValidRate) {
@@ -76,10 +83,24 @@
7683
error = $_('validBitcoinApiTip')
7784
return
7885
}
86+
if (password || confirmPassword) {
87+
if (password !== confirmPassword) {
88+
error = $_('passwordsDoNotMatch')
89+
return
90+
}
91+
if (password.length < 6) {
92+
error = $_('passwordTooShort')
93+
return
94+
}
95+
}
7996
loading = true
8097
error = ''
8198
localStorage.setItem(EXCHANGE_RATE_API_KEY, rateApiKey)
8299
localStorage.setItem(BITCOIN_API_KEY, bitcoinApiKey)
100+
if (password) {
101+
const hashedPassword = await hashPassword(password)
102+
await setPassword(hashedPassword)
103+
}
83104
await fetchExchangeRates()
84105
closeModal()
85106
} catch (err) {
@@ -95,7 +116,8 @@
95116
tabindex="-1"
96117
class="fixed left-0 right-0 top-0 z-50 hidden h-[calc(100%-1rem)] w-full overflow-y-auto overflow-x-hidden p-4 md:inset-0 md:h-full">
97118
<div class="relative h-full w-full max-w-lg md:h-auto md:max-w-md">
98-
<div class="relative mt-16 rounded-lg bg-white pb-8 shadow">
119+
<!-- Modal content -->
120+
<div class="relative mt-8 rounded-lg bg-white pb-8 shadow">
99121
<!-- Modal header -->
100122
<div class="flex items-center justify-between rounded-t border-b p-5">
101123
<h3 class="flex items-center text-lg font-medium text-gray-900 md:text-base">
@@ -113,15 +135,14 @@
113135
<!-- Modal body -->
114136
<div class="flex flex-col items-center justify-center p-6">
115137
<div class="module-warp">
116-
<label for="rateApiKey" class="custom-label">
138+
<label for="rateApiKey" class="custom-label !leading-5">
117139
<a
118140
target="_blank"
119-
class="text-brand hover:text-mark leading-3"
141+
class="text-link hover:text-mark leading-3"
120142
href="https://fine.niceshare.site/projects/wealth-tracker/#如何获取汇率-api-key">
121143
Exchange Rate <br />
122144
API Key
123145
</a>
124-
<i class="text-mark">*</i>
125146
</label>
126147
<input
127148
type="text"
@@ -131,15 +152,14 @@
131152
placeholder={$_('validRateApiTip')} />
132153
</div>
133154

134-
<div class="module-warp mt-4">
135-
<label for="bitcoinApiKey" class="custom-label">
155+
<div class="module-warp">
156+
<label for="bitcoinApiKey" class="custom-label !leading-5">
136157
<a
137158
target="_blank"
138-
class="text-brand hover:text-mark leading-3"
159+
class="text-link hover:text-mark leading-3"
139160
href="https://api-ninjas.com/api/bitcoin">
140161
Bitcoin <br />
141162
API Key
142-
<i class="text-mark">*</i>
143163
</a>
144164
</label>
145165
<input
@@ -149,8 +169,45 @@
149169
placeholder={$_('validBitcoinApiTip')} />
150170
</div>
151171

152-
{#if error}
153-
<p class="text-sm text-red-500">{error}</p>
172+
{#if $isPasswordAllowed}
173+
<div class="inline-flex w-full items-center justify-center pb-4">
174+
<hr class="my-6 h-px w-full border-0 bg-gray-200" />
175+
<span
176+
class="text-warn absolute left-1/2 -translate-x-1/2 bg-white px-3 text-center font-medium leading-5">
177+
{$_('setPasswordTip')}
178+
</span>
179+
</div>
180+
181+
<div class="module-warp">
182+
<label for="passwordInput" class="custom-label">
183+
{$_('setPassword')}
184+
</label>
185+
<input
186+
id="passwordInput"
187+
type="password"
188+
class="custom-input"
189+
bind:value={password}
190+
placeholder={$_('passwordTip')} />
191+
</div>
192+
193+
<div class="module-warp">
194+
<label for="confirmPasswordInput" class="custom-label">
195+
{$_('confirmPassword')}
196+
</label>
197+
<input
198+
id="confirmPasswordInput"
199+
type="password"
200+
class="custom-input"
201+
bind:value={confirmPassword}
202+
placeholder={$_('confirmPasswordTip')} />
203+
</div>
204+
{:else}
205+
<div class="module-warp">
206+
<label for="allowPassword" class="custom-label">
207+
{$_('allowPassword')}
208+
</label>
209+
<Toggle id="allowPassword" disabled checked={!$isPasswordAllowed} />
210+
</div>
154211
{/if}
155212
</div>
156213
<div class="flex items-center justify-center">

client/src/helper/apis.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ export const destroyAssets = (data) => {
2020
return $ajax.delete(genApiPath('assets'), data)
2121
}
2222

23+
export const checkPassword = (data = {}) => {
24+
return $ajax.get(genApiPath('password/check'), data)
25+
}
26+
27+
export const verifyPassword = (password: string) => {
28+
return $ajax.post(genApiPath('password/verify'), { password })
29+
}
30+
31+
export const setPassword = (password: string) => {
32+
return $ajax.post(genApiPath('password/set'), { password })
33+
}
34+
2335
export const getRecords = (data = {}) => {
2436
return $ajax.get(genApiPath('records'), data)
2537
}

0 commit comments

Comments
 (0)