diff --git a/app/Http/Controllers/PreferencesController.php b/app/Http/Controllers/PreferencesController.php
index f8eb3e703d..9d2b0cd591 100644
--- a/app/Http/Controllers/PreferencesController.php
+++ b/app/Http/Controllers/PreferencesController.php
@@ -125,6 +125,13 @@ final class PreferencesController extends Controller
}
$fiscalYearStart = sprintf('%s-%s', Carbon::now()->format('Y'), (string) $fiscalYearStartStr);
$tjOptionalFields = Preferences::get('transaction_journal_optional_fields', [])->data;
+
+ // missing fields will give an error unless set, so:
+ $tjOptionalFields['external_url'] = $tjOptionalFields['external_url'] ?? false;
+ $tjOptionalFields['location'] = $tjOptionalFields['location'] ?? false;
+ $tjOptionalFields['links'] = $tjOptionalFields['links'] ?? false;
+
+
$availableDarkModes = config('firefly.available_dark_modes');
// notifications settings
diff --git a/app/Support/ExpandedForm.php b/app/Support/ExpandedForm.php
index 0abd117ca2..d1ef179f69 100644
--- a/app/Support/ExpandedForm.php
+++ b/app/Support/ExpandedForm.php
@@ -325,6 +325,23 @@ class ExpandedForm
return $html;
}
+ /**
+ * @throws FireflyException
+ */
+ public function hidden(string $name, mixed $value): string
+ {
+ try {
+ $html = view('form.hidden', ['name' => $name, 'value' => $value])->render();
+ } catch (Throwable $e) {
+ Log::debug(sprintf('Could not render hidden(): %s', $e->getMessage()));
+ $html = sprintf('Could not render hidden: %s', $e->getMessage());
+
+ throw new FireflyException($html, 0, $e);
+ }
+
+ return $html;
+ }
+
/**
* @throws FireflyException
*/
diff --git a/resources/assets/v1/src/components/passport/Clients.vue b/resources/assets/v1/src/components/passport/Clients.vue
index 786dc6c88f..4b4d6c1b9f 100644
--- a/resources/assets/v1/src/components/passport/Clients.vue
+++ b/resources/assets/v1/src/components/passport/Clients.vue
@@ -25,280 +25,7 @@
-
-
-
-
-
-
- {{ $t('firefly.profile_oauth_clients_explain') }}
-
-
- {{ $t('firefly.profile_oauth_clients_external_auth') }}
-
-
- {{ $t('firefly.profile_oauth_no_clients') }}
-
-
- {{ $t('firefly.profile_oauth_clients_header') }}
-
-
- | {{ $t('firefly.profile_oauth_client_id') }} |
- {{ $t('firefly.name') }} |
- {{ $t('firefly.actions') }} |
-
-
-
-
-
- |
- {{ client.id }}
- |
-
-
-
- {{ client.name }}
- |
-
-
-
-
- |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ $t('firefly.profile_whoops') }} {{
- $t('firefly.profile_something_wrong')
- }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ $t('firefly.profile_whoops') }} {{
- $t('firefly.profile_something_wrong')
- }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ $t('firefly.profile_oauth_client_secret_expl') }}
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/assets/v1/src/components/passport/PersonalAccessTokens.vue b/resources/assets/v1/src/components/passport/PersonalAccessTokens.vue
index 28e23fd51d..5fd746ea63 100644
--- a/resources/assets/v1/src/components/passport/PersonalAccessTokens.vue
+++ b/resources/assets/v1/src/components/passport/PersonalAccessTokens.vue
@@ -25,168 +25,7 @@
-
-
-
-
-
-
- {{ $t('firefly.explain_pats') }}
-
-
-
- {{ $t('firefly.profile_no_personal_access_token') }}
-
-
-
-
- {{ $t('firefly.profile_personal_access_tokens') }}
-
-
- | {{ $t('firefly.name') }} |
- {{ $t('firefly.expires_at') }} |
- |
-
-
-
-
-
-
- |
- {{ token.name }}
- |
-
-
- {{ new Date(token.expires_at).toLocaleString() }}
- |
-
-
-
-
- {{ $t('firefly.delete') }}
-
- |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
{{ $t('firefly.profile_whoops') }}
- {{ $t('firefly.profile_something_wrong') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ $t('firefly.profile_personal_access_token_explanation') }}
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/assets/v3/js/pages/generic.js b/resources/assets/v3/js/pages/generic.js
index cad2a829fc..90d03b702b 100644
--- a/resources/assets/v3/js/pages/generic.js
+++ b/resources/assets/v3/js/pages/generic.js
@@ -18,7 +18,6 @@
* along with this program. If not, see .
*/
-// CSS
import '../boot/bootstrap.js';
import sidebar from '../pages/shared/sidebar.js';
import dates from './shared/dates.js';
diff --git a/resources/assets/v3/js/pages/profile/oauth.js b/resources/assets/v3/js/pages/profile/oauth.js
new file mode 100644
index 0000000000..a439f0e076
--- /dev/null
+++ b/resources/assets/v3/js/pages/profile/oauth.js
@@ -0,0 +1,303 @@
+/*
+ * oauth.js
+ * Copyright (c) 2026 james@firefly-iii.org
+ *
+ * This file is part of Firefly III (https://github.com/firefly-iii).
+ *
+ * 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 '../../boot/bootstrap.js';
+import sidebar from '../../pages/shared/sidebar.js';
+import dates from '../shared/dates.js';
+import i18next from "i18next";
+
+let index = function () {
+ return {
+ clients: [],
+ i18next: null,
+
+ clientSecret: null,
+
+ createForm: {
+ errors: [],
+ name: '',
+ redirect_uris: '',
+ confidential: true
+ },
+
+ editForm: {
+ errors: [],
+ name: '',
+ redirect_uris: ''
+ },
+
+ accessToken: null,
+
+ tokens: [],
+ scopes: [],
+
+ form: {
+ name: '',
+ scopes: [],
+ errors: []
+ },
+ // showCreateTokenForm
+
+
+
+ init() {
+ this.i18next = i18next;
+ this.getClients();
+ this.getTokens();
+
+ $('#modal-create-token').on('shown.bs.modal', () => {
+ $('#create-token-name').focus();
+ });
+ $('#modal-create-client').on('shown.bs.modal', () => {
+ $('#create-client-name').focus();
+ });
+
+ $('#modal-edit-client').on('shown.bs.modal', () => {
+ $('#edit-client-name').focus();
+ });
+ const textBox = document.getElementById("secret_box");
+ textBox.onfocus = function () {
+ textBox.select();
+
+ // Work around Chrome's little problem
+ textBox.onmouseup = function () {
+ // Prevent further mouseup intervention
+ textBox.onmouseup = null;
+ return false;
+ };
+ };
+
+ const tokenBox = document.getElementById("token_box");
+ tokenBox.onfocus = function () {
+ tokenBox.select();
+
+ // Work around Chrome's little problem
+ tokenBox.onmouseup = function () {
+ // Prevent further mouseup intervention
+ tokenBox.onmouseup = null;
+ return false;
+ };
+ };
+ },
+ /**
+ * Get all of the personal access tokens for the user.
+ */
+ getTokens() {
+ console.log('getTokens()');
+ axios.get('./oauth/personal-access-tokens')
+ .then(response => {
+ console.log(response.data);
+ this.tokens = response.data;
+ });
+ },
+
+ /**
+ * Show the form for creating new tokens.
+ */
+ showCreateTokenForm() {
+ console.log('showCreateTokenForm()');
+ $('#modal-create-token').modal('show');
+ },
+
+ /**
+ * Create a new personal access token.
+ */
+ storePat() {
+ console.log('storePat()');
+ this.accessToken = null;
+
+ this.form.errors = [];
+
+ axios.post('./oauth/personal-access-tokens', this.form)
+ .then(response => {
+ console.log('Successful POST new token, reset form content.');
+ this.form.name = '';
+ this.form.scopes = [];
+ this.form.errors = [];
+
+ this.getTokens();
+ this.showAccessToken(response.data.accessToken);
+
+ })
+ .catch(error => {
+ console.warn('Bad POST new token, show error.');
+ if (typeof error.response.data === 'object') {
+ this.form.errors = _.flatten(_.toArray(error.response.data.errors));
+ } else {
+ this.form.errors = ['Something went wrong. Please try again.'];
+ }
+ });
+ },
+
+
+ /**
+ * Show the given access token to the user.
+ */
+ showAccessToken(accessToken) {
+ console.log('showAccessToken');
+ $('#modal-create-token').modal('hide');
+
+ this.accessToken = accessToken;
+
+ $('#modal-access-token').modal('show');
+ },
+ getClients() {
+ axios.get('./oauth/clients')
+ .then(response => {
+ console.log(response.data);
+ this.clients = response.data;
+ });
+ },
+ showCreateClientForm() {
+ $('#modal-create-client').modal('show');
+ },
+ /**
+ * Persist the client to storage using the given form.
+ */
+ persistClient(method, uri, form, modal) {
+ form.errors = [];
+
+ axios[method](uri, form)
+ .then(response => {
+ this.getClients();
+
+ form.name = '';
+ form.redirect_uris = '';
+ form.errors = [];
+
+ $(modal).modal('hide');
+
+ if (response.data.plainSecret) {
+ this.showClientSecret(response.data.plainSecret);
+ }
+ })
+ .catch(error => {
+
+ if (typeof error.response.data === 'object') {
+ for (const [key, value] of Object.entries(error.response.data.errors)) {
+ console.log(`${key}: ${value}`);
+ form.errors.push(value);
+ }
+ } else {
+ form.errors = ['Something went wrong. Please try again.'];
+ }
+ console.log(form.errors);
+ });
+ },
+ /**
+ * Revoke the given token.
+ */
+ revoke(token) {
+ axios.delete('./oauth/personal-access-tokens/' + token.id)
+ .then(response => {
+ this.getTokens();
+ });
+ },
+
+ /**
+ * Show the given client secret to the user.
+ */
+ showClientSecret(clientSecret) {
+ this.clientSecret = clientSecret;
+
+ $('#modal-client-secret').modal('show');
+ },
+ regenerateSecret(client) {
+ axios.post('./oauth/clients/regenerate/' + client.id)
+ .then(response => {
+ this.clientSecret = response.data.plainSecret;
+
+ $('#modal-client-secret').modal('show');
+ });
+
+ },
+
+ /**
+ * Destroy the given client.
+ */
+ destroy(client) {
+ axios.delete('./oauth/clients/' + client.id)
+ .then(response => {
+ this.getClients();
+ });
+ },
+ /**
+ * Create a new OAuth client for the user.
+ */
+ store() {
+ this.persistClient(
+ 'post',
+ './oauth/clients',
+ this.createForm,
+ '#modal-create-client'
+ );
+ },
+ /**
+ * Edit the given client.
+ */
+ edit(client) {
+ this.editForm.id = client.id;
+ this.editForm.name = client.name;
+ this.editForm.redirect_uris = client.redirect_uris.join(',');
+
+ $('#modal-edit-client').modal('show');
+ },
+
+ /**
+ * Update the client being edited.
+ */
+ update() {
+ this.persistClient(
+ 'put',
+ './oauth/clients/' + this.editForm.id,
+ this.editForm,
+ '#modal-edit-client'
+ );
+ },
+ }
+};
+
+
+const comps = {
+ index,
+ sidebar,
+ dates
+};
+
+function loadPage(comps) {
+ console.log('loadPage');
+ Object.keys(comps).forEach(comp => {
+ let data = comps[comp]();
+ Alpine.data(comp, () => data);
+ console.log(comp);
+ });
+ Alpine.start();
+}
+
+// wait for load until bootstrapped event is received.
+document.addEventListener('firefly-iii-bootstrapped', () => {
+ console.log('Loaded through event listener.');
+ loadPage(comps);
+});
+// or is bootstrapped before event is triggered.
+if (window.bootstrapped) {
+ console.log('Loaded through window variable.');
+ loadPage(comps);
+}
diff --git a/resources/assets/v3/vite.config.js b/resources/assets/v3/vite.config.js
index 5d9789e172..efd1111bc0 100644
--- a/resources/assets/v3/vite.config.js
+++ b/resources/assets/v3/vite.config.js
@@ -65,6 +65,9 @@ export default defineConfig(({command, mode, isSsrBuild, isPreview}) => {
// object groups
+ // profile
+ 'js/pages/profile/oauth.js',
+
// rules
// recurring transactions
diff --git a/resources/views/attachments/delete.blade.php b/resources/views/attachments/delete.blade.php
index 360d98ec5a..bbbb6b00e9 100644
--- a/resources/views/attachments/delete.blade.php
+++ b/resources/views/attachments/delete.blade.php
@@ -17,8 +17,8 @@
{{ trans('form.attachment_areYouSure', ['name' => $attachment->filename]) }}
-
diff --git a/resources/views/budgets/create.blade.php b/resources/views/budgets/create.blade.php
index 411034ed8b..d3338250d3 100644
--- a/resources/views/budgets/create.blade.php
+++ b/resources/views/budgets/create.blade.php
@@ -41,8 +41,8 @@
{!! ExpandedForm::optionsList('create','budget') !!}
-
diff --git a/resources/views/budgets/edit.blade.php b/resources/views/budgets/edit.blade.php
index b1a5c33b6a..df1cf96ff9 100644
--- a/resources/views/budgets/edit.blade.php
+++ b/resources/views/budgets/edit.blade.php
@@ -44,8 +44,8 @@
{!! ExpandedForm::optionsList('update','budget') !!}
-
diff --git a/resources/views/budgets/index.blade.php b/resources/views/budgets/index.blade.php
index dd7baf09d0..188964d6ca 100644
--- a/resources/views/budgets/index.blade.php
+++ b/resources/views/budgets/index.blade.php
@@ -402,8 +402,8 @@
-
diff --git a/resources/views/budgets/no-budget.blade.php b/resources/views/budgets/no-budget.blade.php
index 5641ac2762..9d318cd940 100644
--- a/resources/views/budgets/no-budget.blade.php
+++ b/resources/views/budgets/no-budget.blade.php
@@ -16,7 +16,7 @@
-
+
@if(null !== ($periods ?? null) && count($periods) > 0)
diff --git a/resources/views/categories/create.blade.php b/resources/views/categories/create.blade.php
index 90d267c41c..ad615c6193 100644
--- a/resources/views/categories/create.blade.php
+++ b/resources/views/categories/create.blade.php
@@ -36,8 +36,8 @@
{!! ExpandedForm::optionsList('create','category') !!}
-