-
Hello world!
+
-
-
+
diff --git a/src/components/HelloWorld.vue b/src/components/HelloWorld.vue
new file mode 100644
index 0000000..e1a721c
--- /dev/null
+++ b/src/components/HelloWorld.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
{{ msg }}
+
+ You’ve successfully created a project with
+ Vite +
+ Vue 3.
+
+
+
+
+
diff --git a/src/components/Navigation.vue b/src/components/Navigation.vue
new file mode 100644
index 0000000..bf79784
--- /dev/null
+++ b/src/components/Navigation.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/src/components/SecurityAndPrivacy.vue b/src/components/SecurityAndPrivacy.vue
new file mode 100644
index 0000000..0adabbd
--- /dev/null
+++ b/src/components/SecurityAndPrivacy.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
diff --git a/src/components/__tests__/HelloWorld.spec.ts b/src/components/__tests__/HelloWorld.spec.ts
new file mode 100644
index 0000000..2533202
--- /dev/null
+++ b/src/components/__tests__/HelloWorld.spec.ts
@@ -0,0 +1,11 @@
+import { describe, it, expect } from 'vitest'
+
+import { mount } from '@vue/test-utils'
+import HelloWorld from '../HelloWorld.vue'
+
+describe('HelloWorld', () => {
+ it('renders properly', () => {
+ const wrapper = mount(HelloWorld, { props: { msg: 'Hello Vitest' } })
+ expect(wrapper.text()).toContain('Hello Vitest')
+ })
+})
diff --git a/src/components/security-and-privacy/AuthToken.vue b/src/components/security-and-privacy/AuthToken.vue
new file mode 100644
index 0000000..d79b3d5
--- /dev/null
+++ b/src/components/security-and-privacy/AuthToken.vue
@@ -0,0 +1,381 @@
+
+
+
+
+
+
+
+
+ {{ tokenLabel }}
+ ({{ t('settings', 'Marked for remote wipe') }})
+
+ |
+
+
+ |
+
+
+
+
+ {{ t('settings', 'Allow filesystem access') }}
+
+
+
+ {{ t('settings', 'Rename') }}
+
+
+
+
+
+
+
+ {{ t('settings', 'Revoke') }}
+
+
+ {{ t('settings', 'Wipe device') }}
+
+
+
+ {{ t('settings', 'Revoking this token might prevent the wiping of your device if it has not started the wipe yet.') }}
+
+
+
+ |
+
+
+
+
+
+
diff --git a/src/components/security-and-privacy/AuthTokenList.vue b/src/components/security-and-privacy/AuthTokenList.vue
new file mode 100644
index 0000000..5a8859c
--- /dev/null
+++ b/src/components/security-and-privacy/AuthTokenList.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+ {{ t('settings', 'Actions') }}
+
+ |
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/security-and-privacy/AuthTokenSection.vue b/src/components/security-and-privacy/AuthTokenSection.vue
new file mode 100644
index 0000000..a168984
--- /dev/null
+++ b/src/components/security-and-privacy/AuthTokenSection.vue
@@ -0,0 +1,57 @@
+
+
+
+
+
{{ t('settings', 'Devices & sessions', {}, undefined, {sanitize: false}) }}
+
+ {{ t('settings', 'Web, desktop and mobile clients currently logged in to your account.') }}
+
+
+
+
+
+
+
diff --git a/src/components/security-and-privacy/AuthTokenSetup.vue b/src/components/security-and-privacy/AuthTokenSetup.vue
new file mode 100644
index 0000000..9320c53
--- /dev/null
+++ b/src/components/security-and-privacy/AuthTokenSetup.vue
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/security-and-privacy/AuthTokenSetupDialog.vue b/src/components/security-and-privacy/AuthTokenSetupDialog.vue
new file mode 100644
index 0000000..6749798
--- /dev/null
+++ b/src/components/security-and-privacy/AuthTokenSetupDialog.vue
@@ -0,0 +1,221 @@
+
+
+
+
+ {{ t('settings', 'Use the credentials below to configure your app or device. For security reasons this password will only be shown once.') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('settings', 'Show QR code for mobile apps') }}
+
+
+
+
+
+
+
+
+
diff --git a/src/logger.ts b/src/logger.ts
new file mode 100644
index 0000000..b6144e2
--- /dev/null
+++ b/src/logger.ts
@@ -0,0 +1,28 @@
+/**
+ * @copyright 2020 Christoph Wurst
+ *
+ * @author Christoph Wurst
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+import { getLoggerBuilder } from '@nextcloud/logger'
+
+export default getLoggerBuilder()
+ .setApp('settings')
+ .detectUser()
+ .build()
diff --git a/src/main.js b/src/main.js
deleted file mode 100644
index 1854470..0000000
--- a/src/main.js
+++ /dev/null
@@ -1,6 +0,0 @@
-import Vue from 'vue'
-import App from './App.vue'
-Vue.mixin({ methods: { t, n } })
-
-const View = Vue.extend(App)
-new View().$mount('#simplesettings')
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..c127cf1
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,9 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import App from './App.vue'
+
+const app = createApp(App)
+
+app.use(createPinia())
+
+app.mount('#simplesettings')
diff --git a/src/store/authtoken.ts b/src/store/authtoken.ts
new file mode 100644
index 0000000..3002f3f
--- /dev/null
+++ b/src/store/authtoken.ts
@@ -0,0 +1,224 @@
+/**
+ * @copyright 2023 Ferdinand Thiessen
+ *
+ * @author Ferdinand Thiessen
+ *
+ * @license AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ */
+import { showError } from '@nextcloud/dialogs'
+import { loadState } from '@nextcloud/initial-state'
+import { translate as t } from '@nextcloud/l10n'
+import { confirmPassword } from '@nextcloud/password-confirmation'
+import { generateUrl } from '@nextcloud/router'
+import { defineStore } from 'pinia'
+
+import axios from '@nextcloud/axios'
+import logger from '../logger'
+
+const BASE_URL = generateUrl('/settings/personal/authtokens')
+
+declare global {
+ interface Window {
+ OC: any;
+ oc_defaults: {
+ productName: string;
+ }
+ }
+}
+
+const confirm = () => {
+ return new Promise(resolve => {
+ window.OC.dialogs.confirm(
+ t('settings', 'Do you really want to wipe your data from this device?'),
+ t('settings', 'Confirm wipe'),
+ resolve,
+ true,
+ )
+ })
+}
+
+export enum TokenType {
+ TEMPORARY_TOKEN = 0,
+ PERMANENT_TOKEN = 1,
+ WIPING_TOKEN = 2,
+}
+
+export interface IToken {
+ id: number
+ canDelete: boolean
+ canRename: boolean
+ current?: true
+ /**
+ * Last activity as UNIX timestamp (in seconds)
+ */
+ lastActivity: number
+ name: string
+ type: TokenType
+ scope: Record
+}
+
+export interface ITokenResponse {
+ /**
+ * The device token created
+ */
+ deviceToken: IToken
+ /**
+ * User who is assigned with this token
+ */
+ loginName: string
+ /**
+ * The token for authentication
+ */
+ token: string
+}
+
+export const useAuthTokenStore = defineStore('auth-token', {
+ state() {
+ return {
+ tokens: loadState('settings', 'app_tokens', []),
+ }
+ },
+ actions: {
+ /**
+ * Update a token on server
+ * @param token Token to update
+ */
+ async updateToken(token: IToken) {
+ const { data } = await axios.put(`${BASE_URL}/${token.id}`, token)
+ return data
+ },
+
+ /**
+ * Add a new token
+ * @param name The token name
+ */
+ async addToken(name: string) {
+ logger.debug('Creating a new app token')
+
+ try {
+ await confirmPassword()
+
+ const { data } = await axios.post(BASE_URL, { name })
+ this.tokens.push(data.deviceToken)
+ logger.debug('App token created')
+ return data
+ } catch (error) {
+ return null
+ }
+ },
+
+ /**
+ * Delete a given app token
+ * @param token Token to delete
+ */
+ async deleteToken(token: IToken) {
+ logger.debug('Deleting app token', { token })
+
+ this.tokens = this.tokens.filter(({ id }) => id !== token.id)
+
+ try {
+ await axios.delete(`${BASE_URL}/${token.id}`)
+ logger.debug('App token deleted')
+ return true
+ } catch (error) {
+ logger.error('Could not delete app token', { error })
+ showError(t('settings', 'Could not delete the app token'))
+ // Restore
+ this.tokens.push(token)
+ }
+ return false
+ },
+
+ /**
+ * Wipe a token and the connected device
+ * @param token Token to wipe
+ */
+ async wipeToken(token: IToken) {
+ logger.debug('Wiping app token', { token })
+
+ try {
+ await confirmPassword()
+
+ if (!(await confirm())) {
+ logger.debug('Wipe aborted by user')
+ return
+ }
+
+ await axios.post(`${BASE_URL}/wipe/${token.id}`)
+ logger.debug('App token marked for wipe', { token })
+
+ token.type = TokenType.WIPING_TOKEN
+ token.canRename = false // wipe tokens can not be renamed
+ return true
+ } catch (error) {
+ logger.error('Could not wipe app token', { error })
+ showError(t('settings', 'Error while wiping the device with the token'))
+ }
+ return false
+ },
+
+ /**
+ * Rename an existing token
+ * @param token The token to rename
+ * @param newName The new name to set
+ */
+ async renameToken(token: IToken, newName: string) {
+ logger.debug(`renaming app token ${token.id} from ${token.name} to '${newName}'`)
+
+ const oldName = token.name
+ token.name = newName
+
+ try {
+ await this.updateToken(token)
+ logger.debug('App token name updated')
+ return true
+ } catch (error) {
+ logger.error('Could not update app token name', { error })
+ showError(t('settings', 'Error while updating device token name'))
+ // Restore
+ token.name = oldName
+ }
+ return false
+ },
+
+ /**
+ * Set scope of the token
+ * @param token Token to set scope
+ * @param scope scope to set
+ * @param value value to set
+ */
+ async setTokenScope(token: IToken, scope: string, value: boolean) {
+ logger.debug('Updating app token scope', { token, scope, value })
+
+ const oldVal = token.scope[scope]
+ token.scope[scope] = value
+
+ try {
+ await this.updateToken(token)
+ logger.debug('app token scope updated')
+ return true
+ } catch (error) {
+ logger.error('could not update app token scope', { error })
+ showError(t('settings', 'Error while updating device token scope'))
+ // Restore
+ token.scope[scope] = oldVal
+ }
+ return false
+ },
+ },
+
+})
diff --git a/tsconfig.app.json b/tsconfig.app.json
new file mode 100644
index 0000000..7f48cde
--- /dev/null
+++ b/tsconfig.app.json
@@ -0,0 +1,17 @@
+{
+ "extends": "@vue/tsconfig/tsconfig.dom.json",
+ "include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
+ "exclude": ["src/**/__tests__/*"],
+ "compilerOptions": {
+ // Fix for the following error caused by many @nextcloud/vue components lacking type defs:
+ // Could not find a declaration file for module '@nextcloud/vue/dist/...s'. '.../.mjs' implicitly has an 'any' type.
+ "noImplicitAny": false,
+ "composite": true,
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..100cf6a
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "files": [],
+ "references": [
+ {
+ "path": "./tsconfig.node.json"
+ },
+ {
+ "path": "./tsconfig.app.json"
+ },
+ {
+ "path": "./tsconfig.vitest.json"
+ }
+ ]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..f094063
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,19 @@
+{
+ "extends": "@tsconfig/node20/tsconfig.json",
+ "include": [
+ "vite.config.*",
+ "vitest.config.*",
+ "cypress.config.*",
+ "nightwatch.conf.*",
+ "playwright.config.*"
+ ],
+ "compilerOptions": {
+ "composite": true,
+ "noEmit": true,
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "types": ["node"]
+ }
+}
diff --git a/tsconfig.vitest.json b/tsconfig.vitest.json
new file mode 100644
index 0000000..571995d
--- /dev/null
+++ b/tsconfig.vitest.json
@@ -0,0 +1,11 @@
+{
+ "extends": "./tsconfig.app.json",
+ "exclude": [],
+ "compilerOptions": {
+ "composite": true,
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
+
+ "lib": [],
+ "types": ["node", "jsdom"]
+ }
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..772a171
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,25 @@
+import path from 'node:path'
+import { createAppConfig } from '@nextcloud/vite-config'
+
+// See
+// - https://github.com/nextcloud-libraries/nextcloud-vite-config/tree/v2.0.0
+// - https://github.com/nextcloud-libraries/nextcloud-vite-config/blob/v2.0.0/lib/appConfig.ts
+export default createAppConfig({
+ // entry points: {name: script}
+ main: path.resolve(__dirname, 'src/main.ts'),
+}, {
+ inlineCSS: true,
+ config: () => ({
+ build: {
+ // The dist filename /js/main.js is decided by
+ // - folder: nextcloud:lib/public/Util::addScript() (assumed to be in /js/)
+ // - filename: app:/template/mindex.php (explicitly passed to addScript())
+ outDir: 'js',
+ rollupOptions: {
+ output: {
+ entryFileNames: () => 'main.js',
+ }
+ }
+ }
+ })
+})
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..bf9304d
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,21 @@
+import { fileURLToPath } from 'node:url'
+import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
+import viteConfig from './vite.config'
+import vue from '@vitejs/plugin-vue'
+
+export default mergeConfig(
+ viteConfig({ mode: 'development', command: 'build' }),
+ defineConfig({
+ plugins: [
+ // This was necessary to fix
+ // "Error: Failed to parse source for import analysis because the content contains invalid JS syntax. Install @vitejs/plugin-vue to handle .vue files."
+ // I assumed this was already defined in viteConfig and retained in merge
+ vue(),
+ ],
+ test: {
+ environment: 'jsdom',
+ exclude: [...configDefaults.exclude, 'e2e/**'],
+ root: fileURLToPath(new URL('./', import.meta.url))
+ }
+ })
+)
diff --git a/webpack.js b/webpack.js
deleted file mode 100644
index 49ae69e..0000000
--- a/webpack.js
+++ /dev/null
@@ -1,27 +0,0 @@
-const webpackConfig = require('@nextcloud/webpack-vue-config')
-const ESLintPlugin = require('eslint-webpack-plugin')
-const StyleLintPlugin = require('stylelint-webpack-plugin')
-const path = require('path')
-
-webpackConfig.entry = {
- main: { import: path.join(__dirname, 'src', 'main.js'), filename: 'main.js' },
-}
-
-webpackConfig.plugins.push(
- new ESLintPlugin({
- extensions: ['js', 'vue'],
- files: 'src',
- }),
-)
-webpackConfig.plugins.push(
- new StyleLintPlugin({
- files: 'src/**/*.{css,scss,vue}',
- }),
-)
-
-webpackConfig.module.rules.push({
- test: /\.svg$/i,
- type: 'asset/source',
-})
-
-module.exports = webpackConfig