Files
photoprism/frontend/src/dialog/account/passcode.vue

410 lines
15 KiB
Vue

<template>
<v-dialog :value="show" persistent max-width="500" class="modal-dialog p-account-passcode-dialog" @keydown.esc="close">
<v-form ref="form" lazy-validation accept-charset="UTF-8" class="form-password" @submit.prevent>
<v-card raised elevation="24">
<v-card-title class="pa-2">
<v-row class="pa-2">
<v-col cols="10" class="text-left">
<h3 class="headline pa-0">
<translate>2-Factor Authentication</translate>
</h3>
</v-col>
<v-col cols="2" class="text-right">
<!-- TODO: change this icon -->
<v-icon v-if="page === 'setup'" size="28" color="primary">gpp_maybe</v-icon>
<!-- TODO: change this icon -->
<v-icon v-else-if="page === 'deactivate'" size="28" color="primary">gpp_good</v-icon>
<v-icon v-else size="28" color="primary">mdi-cog</v-icon>
</v-col>
</v-row>
</v-card-title>
<!-- Setup -->
<template v-if="page === 'setup'">
<v-card-text class="py-0 px-2">
<v-row align="start">
<v-col cols="12" class="pa-2 body-2">
<translate>After entering your password for confirmation, you can set up two-factor authentication with a compatible authenticator app or device:</translate>
</v-col>
<v-col cols="12" class="pa-2">
<v-text-field
v-model="password"
:disabled="busy"
name="password"
:type="showPassword ? 'text' : 'password'"
:label="$gettext('Password')"
hide-details
required
autofocus
solo
flat
autocorrect="off"
autocapitalize="none"
autocomplete="current-password"
class="input-password text-selectable"
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
prepend-inner-icon="mdi-lock"
color="secondary-dark"
@click:append="showPassword = !showPassword"
@keyup.enter="onSetup"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" class="pa-2 body-1">
<translate>Enabling two-factor authentication means that you will need a randomly generated verification code to log in, so even if someone gains access to your password, they will not be able to access your account.</translate>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="pa-2">
<v-row class="pa-2">
<v-col cols="12" class="text-right">
<v-btn depressed color="secondary-light" class="action-close ml-0" @click.stop="close">
<translate>Close</translate>
</v-btn>
<v-btn depressed color="primary-button" class="action-setup white--text compact mr-0" :disabled="setupDisabled()" @click.stop="onSetup">
<translate>Setup</translate>
</v-btn>
</v-col>
</v-row>
</v-card-actions>
</template>
<!-- Confirm -->
<template v-else-if="page === 'confirm'">
<v-card-text class="py-0 px-2">
<v-row align="start">
<v-col cols="12" class="pa-2 body-1">
<translate>Scan the QR code with your authenticator app or use the setup key shown below and then enter the generated verification code:</translate>
</v-col>
<v-col cols="12" class="pa-2">
<img :src="key.QRCode" class="width-100" alt="QR Code" />
</v-col>
</v-row>
<v-row>
<v-col cols="12" class="pa-2 subtitle-1 text-center">
<pre class="clickable" @click.stop.prevent="copyText(key.Secret)">{{ key.Secret }}</pre>
</v-col>
<v-col cols="12" class="pa-2">
<!-- TODO: change this icon -->
<v-text-field
v-model="code"
:disabled="busy"
name="one-time-code"
type="text"
:label="$gettext('Verification Code')"
mask="### ###"
pattern="[0-9]*"
inputmode="numeric"
hide-details
required
solo
autocorrect="off"
autocapitalize="none"
autocomplete="one-time-code"
class="input-code"
color="secondary-dark"
prepend-inner-icon="verified_user"
@keyup.enter="onConfirm"
></v-text-field>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="pa-2">
<v-row class="pa-2">
<v-col cols="12" class="text-right">
<v-btn depressed color="secondary-light" class="action-cancel ml-0" @click.stop="close">
<translate>Cancel</translate>
</v-btn>
<v-btn depressed color="primary-button" class="action-confirm white--text compact mr-0" :disabled="code.length !== 6" @click.stop="onConfirm">
<translate>Confirm</translate>
</v-btn>
</v-col>
</v-row>
</v-card-actions>
</template>
<!-- Activate -->
<template v-else-if="page === 'activate'">
<v-card-text class="py-0 px-2">
<v-row align="start">
<v-col cols="12" class="pa-2 body-2">
<translate>Use the following recovery code to access your account when you are unable to generate a valid verification code with your authenticator app:</translate>
</v-col>
<v-col cols="12" class="pa-2">
<v-text-field
v-model="key.RecoveryCode"
type="text"
mask="nnn nnn nnn nnn"
hide-details
readonly
solo
flat
autocorrect="off"
autocapitalize="none"
autocomplete="off"
append-icon="mdi-content-copy"
class="input-recoverycode"
color="secondary-dark"
@click:append="onCopyRecoveryCode"
></v-text-field>
</v-col>
<v-col cols="12" class="pa-2 body-1">
<translate>To avoid being locked out of your account, please download, print or copy this recovery code now and keep it in a safe place.</translate>
<translate>It is a one-time use code that will disable 2FA for your account when you use it.</translate>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="pa-2">
<v-row class="pa-2">
<v-col cols="12" class="text-right">
<v-btn depressed color="secondary-light" class="action-cancel ml-0" @click.stop="close">
<translate>Cancel</translate>
</v-btn>
<v-btn v-if="recoveryCodeCopied" depressed color="primary-button" class="action-activate white--text compact mr-0" @click.stop="onActivate">
<translate>Activate</translate>
</v-btn>
<v-btn v-else depressed color="primary-button" class="action-copy white--text compact mr-0" @click.stop="onCopyRecoveryCode">
<translate>Copy</translate>
</v-btn>
</v-col>
</v-row>
</v-card-actions>
</template>
<!-- Deactivate -->
<template v-else-if="page === 'deactivate'">
<v-card-text class="py-0 px-2">
<v-row align="start">
<v-col cols="12" class="pa-2 body-2">
<translate>Two-factor authentication has been enabled for your account.</translate>
</v-col>
<v-col cols="12" class="pa-2 body-1">
<translate>If you lose access to your authenticator app or device, you can use your recovery code to regain access to your account.</translate>
<translate>It is a one-time use code that will disable 2FA for your account when you use it.</translate>
</v-col>
<v-col cols="12" class="pa-2 body-1">
<translate>To switch to a new authenticator app or device, first deactivate two-factor authentication and then reactivate it:</translate>
</v-col>
<v-col cols="12" class="pa-2">
<v-text-field
v-model="password"
:disabled="busy"
name="password"
:type="showPassword ? 'text' : 'password'"
hide-details
required
solo
flat
autocorrect="off"
autocapitalize="none"
autocomplete="current-password"
:label="$gettext('Password')"
class="input-password text-selectable"
:append-icon="showPassword ? 'mdi-eye' : 'mdi-eye-off'"
prepend-inner-icon="mdi-lock"
color="secondary-dark"
@click:append="showPassword = !showPassword"
@keyup.enter="onDeactivate"
></v-text-field>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="pa-2">
<v-row class="pa-2">
<v-col cols="12" class="text-right">
<v-btn depressed color="primary-button" class="action-deactivate white--text compact ml-0" :disabled="setupDisabled()" @click.stop="onDeactivate">
<translate>Deactivate</translate>
</v-btn>
<v-btn depressed color="secondary-light" class="action-close mr-0" @click.stop="close">
<translate>Close</translate>
</v-btn>
</v-col>
</v-row>
</v-card-actions>
</template>
<!-- Not Available -->
<template v-else-if="page === 'not_available'">
<v-card-text class="py-0 px-2">
<v-row align="start">
<v-col cols="12" class="pa-2 body-2">
<translate>Only locally managed accounts can be set up for authentication with 2FA.</translate>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="pa-2">
<v-row class="pa-2">
<v-col cols="12" class="text-right">
<v-btn depressed color="secondary-light" class="action-close mr-0" @click.stop="close">
<translate>Close</translate>
</v-btn>
</v-col>
</v-row>
</v-card-actions>
</template>
</v-card>
</v-form>
</v-dialog>
</template>
<script>
import Util from "common/util";
export default {
name: "PAccountPasscodeDialog",
props: {
show: Boolean,
model: {
type: Object,
default: () => this.$session.getUser(),
},
},
data() {
return {
busy: false,
isDemo: this.$config.get("demo"),
isPublic: this.$config.get("public"),
code: "",
recoveryCodeCopied: false,
password: "",
showPassword: false,
session: this.$session,
minLength: this.$config.get("passwordLength"),
maxLength: 72,
rtl: this.$rtl,
key: {},
};
},
computed: {
page() {
if (!this.session.hasPassword()) {
return "not_available";
} else if (this.model?.AuthMethod === "2fa") {
return "deactivate";
} else if (this.key?.Type === "totp") {
if (!this.key?.VerifiedAt) {
return "confirm";
} else if (!this.key?.ActivatedAt) {
return "activate";
} else {
return "deactivate";
}
}
return "setup";
},
},
watch: {
show: function (show) {
if (show) {
this.reset();
}
},
},
created() {
if (this.isPublic && !this.isDemo) {
this.$emit("close");
}
},
methods: {
async copyText(text) {
if (!text) {
return;
}
try {
await Util.copyToMachineClipboard(text);
this.$notify.success(this.$gettext("Copied to clipboard"));
} catch (error) {
this.$notify.error(this.$gettext("Failed copying to clipboard"));
}
},
reset() {
this.code = "";
this.password = "";
this.showPassword = false;
this.recoveryCodeCopied = false;
this.updateUser();
},
updateUser() {
this.$emit("updateUser");
},
setupDisabled() {
return this.isDemo || this.busy || this.password.length < this.minLength;
},
onSetup() {
if (this.busy || this.password === "") {
return;
}
this.busy = true;
this.model
.createPasscode(this.password)
.then((resp) => {
this.key = resp;
})
.finally(() => {
this.busy = false;
});
},
onConfirm() {
if (this.busy || this.code === "") {
return;
}
this.busy = true;
this.model
.confirmPasscode(this.code)
.then((resp) => {
this.key = resp;
this.$notify.success(this.$gettext("Successfully verified"));
})
.finally(() => {
this.busy = false;
this.code = "";
this.password = "";
this.showPassword = false;
this.recoveryCodeCopied = false;
});
},
onCopyRecoveryCode() {
this.copyText(this.key.RecoveryCode);
this.recoveryCodeCopied = true;
},
onActivate() {
if (this.busy) {
return;
}
this.busy = true;
this.model
.activatePasscode()
.then((resp) => {
this.key = resp;
this.$notify.success(this.$gettext("Successfully activated"));
})
.finally(() => {
this.busy = false;
this.reset();
});
},
onDeactivate() {
if (this.busy || this.password === "") {
return;
}
this.busy = true;
this.model
.deactivatePasscode(this.password)
.then(() => {
this.$notify.success(this.$gettext("Settings saved"));
this.reset();
this.key = {};
})
.finally(() => {
this.busy = false;
});
},
close() {
if (this.busy) {
return;
}
this.$emit("close");
},
},
};
</script>