First Commit
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
# JWT API Authentication for Odoo 19
|
||||
|
||||
Modul `grt_jwt_token` menyediakan autentikasi JWT untuk aplikasi eksternal yang ingin mengakses endpoint API Odoo dengan pola:
|
||||
|
||||
1. Aplikasi eksternal mengirim `client_id` dan `client_secret`.
|
||||
2. Odoo memvalidasi API Client.
|
||||
3. Odoo mengembalikan JWT access token.
|
||||
4. Aplikasi eksternal memakai token tersebut sebagai `Authorization: Bearer <token>` untuk mengakses endpoint API yang dilindungi.
|
||||
|
||||
Modul ini cocok untuk integrasi API dan SSO berbasis token antar aplikasi. Untuk login otomatis ke UI web Odoo, masih diperlukan flow tambahan yang membuat Odoo session secara eksplisit.
|
||||
|
||||
## Fitur
|
||||
|
||||
- Menu aplikasi utama: **JWT Auth**.
|
||||
- Model konfigurasi API Client.
|
||||
- Generate `client_id` otomatis.
|
||||
- Generate dan rotasi `client_secret`.
|
||||
- Penyimpanan secret sebagai hash PBKDF2, bukan plaintext.
|
||||
- Token JWT dengan claim `iss`, `aud`, `exp`, `iat`, `jti`, `sub`, dan `client_id`.
|
||||
- Decorator `@check_jwt_auth` untuk melindungi controller custom.
|
||||
- Contoh endpoint protected: `/api/ifc/projects`.
|
||||
|
||||
## Struktur Modul
|
||||
|
||||
```text
|
||||
grt_jwt_token/
|
||||
+-- __manifest__.py
|
||||
+-- __init__.py
|
||||
+-- README.md
|
||||
+-- controllers/
|
||||
| +-- __init__.py
|
||||
| +-- main.py
|
||||
+-- models/
|
||||
| +-- __init__.py
|
||||
| +-- api_client.py
|
||||
+-- security/
|
||||
| +-- ir.model.access.csv
|
||||
+-- static/
|
||||
| +-- description/
|
||||
| | +-- icon.png
|
||||
| | +-- icon_128.png
|
||||
| +-- src/img/
|
||||
| +-- jwt_auth_icon.png
|
||||
+-- views/
|
||||
+-- api_client_views.xml
|
||||
```
|
||||
|
||||
## Konfigurasi Odoo
|
||||
|
||||
Tambahkan `jwt_secret` di file `odoo.conf`.
|
||||
|
||||
```ini
|
||||
[options]
|
||||
jwt_secret = ganti_dengan_secret_panjang_minimal_32_karakter
|
||||
```
|
||||
|
||||
Ketentuan:
|
||||
|
||||
- Minimal 32 karakter.
|
||||
- Jangan pakai secret contoh di production.
|
||||
- Simpan secret hanya di server.
|
||||
- Setelah mengubah `odoo.conf`, restart Odoo.
|
||||
|
||||
Contoh konfigurasi lokal:
|
||||
|
||||
```ini
|
||||
db_name = odoo19
|
||||
http_port = 8071
|
||||
jwt_secret = W2V8bVN38YJZJQhcew8Fcgw6vG5Ab79hbRzD3SpjJvGQxNPvKcNFQ8syb6h9PqRr
|
||||
```
|
||||
|
||||
## Instalasi
|
||||
|
||||
1. Pastikan folder `grt_jwt_token` berada di `addons_path`.
|
||||
2. Restart Odoo.
|
||||
3. Buka **Apps**.
|
||||
4. Update Apps List jika modul belum muncul.
|
||||
5. Install **JWT API Authentication**.
|
||||
|
||||
Atau lewat CLI:
|
||||
|
||||
```powershell
|
||||
$env:PYTHONPATH='C:\odoo19\server'
|
||||
.\.venv\Scripts\python.exe C:\odoo19\server\odoo-bin -c .\odoo.conf -d odoo19 -i grt_jwt_token --stop-after-init
|
||||
```
|
||||
|
||||
Untuk update modul:
|
||||
|
||||
```powershell
|
||||
$env:PYTHONPATH='C:\odoo19\server'
|
||||
.\.venv\Scripts\python.exe C:\odoo19\server\odoo-bin -c .\odoo.conf -d odoo19 -u grt_jwt_token --stop-after-init
|
||||
```
|
||||
|
||||
## Membuat API Client
|
||||
|
||||
1. Login sebagai Administrator.
|
||||
2. Buka app **JWT Auth**.
|
||||
3. Buat record baru.
|
||||
4. Isi:
|
||||
- **Application Name**: nama aplikasi eksternal.
|
||||
- **Odoo User**: user Odoo yang akan dipakai ketika token digunakan.
|
||||
- **Token Lifetime (seconds)**: masa berlaku token, default `7200`.
|
||||
5. Simpan.
|
||||
6. Salin `Client ID` dan `New Client Secret`.
|
||||
|
||||
Penting: `client_secret` hanya ditampilkan saat dibuat atau setelah tombol **Regenerate Secret** ditekan. Setelah halaman ditutup, secret tidak bisa dilihat lagi karena database hanya menyimpan hash.
|
||||
|
||||
## Endpoint Token
|
||||
|
||||
URL:
|
||||
|
||||
```text
|
||||
POST /api/auth/token
|
||||
```
|
||||
|
||||
Content-Type:
|
||||
|
||||
```text
|
||||
application/json
|
||||
```
|
||||
|
||||
Body:
|
||||
|
||||
```json
|
||||
{
|
||||
"client_id": "CLIENT_ID_ANDA",
|
||||
"client_secret": "CLIENT_SECRET_ANDA"
|
||||
}
|
||||
```
|
||||
|
||||
`secret_key` juga diterima sebagai alias dari `client_secret` untuk kompatibilitas integrasi.
|
||||
|
||||
Response sukses:
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "JWT_TOKEN",
|
||||
"expires_in": 7200,
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
```
|
||||
|
||||
Response gagal:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Invalid credentials."
|
||||
}
|
||||
```
|
||||
|
||||
## Contoh Request Token
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
$body = @{
|
||||
client_id = "CLIENT_ID_ANDA"
|
||||
client_secret = "CLIENT_SECRET_ANDA"
|
||||
} | ConvertTo-Json
|
||||
|
||||
Invoke-RestMethod `
|
||||
-Uri "http://localhost:8071/api/auth/token" `
|
||||
-Method Post `
|
||||
-Body $body `
|
||||
-ContentType "application/json"
|
||||
```
|
||||
|
||||
cURL:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8071/api/auth/token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"client_id":"CLIENT_ID_ANDA","client_secret":"CLIENT_SECRET_ANDA"}'
|
||||
```
|
||||
|
||||
## Mengakses Endpoint Protected
|
||||
|
||||
Contoh endpoint:
|
||||
|
||||
```text
|
||||
GET /api/ifc/projects
|
||||
```
|
||||
|
||||
Header:
|
||||
|
||||
```text
|
||||
Authorization: Bearer JWT_TOKEN
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
$headers = @{
|
||||
Authorization = "Bearer JWT_TOKEN"
|
||||
}
|
||||
|
||||
Invoke-RestMethod `
|
||||
-Uri "http://localhost:8071/api/ifc/projects" `
|
||||
-Method Get `
|
||||
-Headers $headers
|
||||
```
|
||||
|
||||
cURL:
|
||||
|
||||
```bash
|
||||
curl http://localhost:8071/api/ifc/projects \
|
||||
-H "Authorization: Bearer JWT_TOKEN"
|
||||
```
|
||||
|
||||
Response contoh:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "JWT token is valid.",
|
||||
"user_id": 2,
|
||||
"user_name": "Administrator",
|
||||
"client_id": "CLIENT_ID_ANDA",
|
||||
"data": [
|
||||
"Proyek_IFC_Gedung_A",
|
||||
"Proyek_IFC_Infrastruktur_B"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Melindungi Controller Custom
|
||||
|
||||
Import decorator dari controller modul:
|
||||
|
||||
```python
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.grt_jwt_token.controllers.main import check_jwt_auth, json_response
|
||||
|
||||
|
||||
class MyApiController(http.Controller):
|
||||
|
||||
@http.route('/api/my/resource', type='http', auth='public', methods=['GET'], csrf=False)
|
||||
@check_jwt_auth
|
||||
def my_resource(self, **kwargs):
|
||||
return json_response({
|
||||
'user_id': request.env.user.id,
|
||||
'user_name': request.env.user.name,
|
||||
})
|
||||
```
|
||||
|
||||
Saat token valid, decorator akan:
|
||||
|
||||
- Decode dan validasi JWT.
|
||||
- Memastikan client masih aktif.
|
||||
- Memastikan user Odoo masih aktif.
|
||||
- Mengisi `request.jwt_payload`.
|
||||
- Mengisi `request.jwt_client`.
|
||||
- Mengisi `request.jwt_user_id`.
|
||||
- Menjalankan `request.update_env(user=user_id)`.
|
||||
|
||||
## Claim JWT
|
||||
|
||||
Token berisi claim berikut:
|
||||
|
||||
| Claim | Isi |
|
||||
| --- | --- |
|
||||
| `iss` | Issuer, default `odoo` |
|
||||
| `aud` | Audience, default `odoo-api` |
|
||||
| `exp` | Waktu kedaluwarsa token |
|
||||
| `iat` | Waktu token diterbitkan |
|
||||
| `jti` | ID unik token |
|
||||
| `sub` | ID user Odoo dalam format string |
|
||||
| `client_id` | Client ID API Client |
|
||||
|
||||
## Keamanan
|
||||
|
||||
Rekomendasi production:
|
||||
|
||||
- Gunakan HTTPS.
|
||||
- Gunakan `jwt_secret` yang panjang dan random.
|
||||
- Jangan commit `jwt_secret` production ke repository.
|
||||
- Buat satu API Client per aplikasi eksternal.
|
||||
- Mapping API Client ke user Odoo dengan hak akses minimum.
|
||||
- Rotasi secret secara berkala dengan tombol **Regenerate Secret**.
|
||||
- Nonaktifkan API Client yang tidak digunakan.
|
||||
- Batasi masa berlaku token sesuai kebutuhan.
|
||||
- Tambahkan rate limiting di reverse proxy jika endpoint dibuka ke internet.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error: JWT secret is not configured
|
||||
|
||||
Penyebab:
|
||||
|
||||
- `jwt_secret` belum ada di `odoo.conf`.
|
||||
- Panjang `jwt_secret` kurang dari 32 karakter.
|
||||
- Odoo belum direstart setelah config diubah.
|
||||
|
||||
Solusi:
|
||||
|
||||
1. Tambahkan `jwt_secret` yang valid.
|
||||
2. Restart Odoo.
|
||||
|
||||
### Error: Invalid credentials
|
||||
|
||||
Penyebab:
|
||||
|
||||
- `client_id` salah.
|
||||
- `client_secret` salah.
|
||||
- API Client tidak aktif.
|
||||
- User Odoo yang dipetakan tidak aktif.
|
||||
|
||||
### Error: Token has expired
|
||||
|
||||
Token melewati `Token Lifetime (seconds)`. Minta token baru melalui `/api/auth/token`.
|
||||
|
||||
### Menu JWT Auth belum muncul
|
||||
|
||||
Jika modul sudah ter-install tapi menu belum terlihat:
|
||||
|
||||
1. Logout.
|
||||
2. Hapus local storage browser untuk `webclient_menus` dan `webclient_menus_version`.
|
||||
3. Login ulang atau pakai private/incognito window.
|
||||
|
||||
## Catatan Batasan
|
||||
|
||||
Modul ini belum membuat session login web Odoo otomatis. Token JWT dipakai untuk akses endpoint API yang memakai `@check_jwt_auth`. Jika membutuhkan SSO penuh ke UI Odoo, perlu tambahan flow session seperti OAuth/OIDC callback atau endpoint login session yang divalidasi dengan signature.
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
'name': 'JWT API Authentication',
|
||||
'version': '1.0.2',
|
||||
'category': 'Tools',
|
||||
'summary': 'JWT authentication endpoint for external applications',
|
||||
'description': """
|
||||
JWT API Authentication for Odoo 19.
|
||||
|
||||
Provides API Client configuration, JWT token generation, and a decorator for
|
||||
protecting custom HTTP controllers with Bearer token authentication.
|
||||
""",
|
||||
'author': 'GRT',
|
||||
'depends': ['base', 'web'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/api_client_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
from . import main
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,130 @@
|
||||
import datetime
|
||||
import functools
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import jwt
|
||||
|
||||
from odoo import fields, http
|
||||
from odoo.http import request
|
||||
from odoo.tools import config
|
||||
|
||||
|
||||
JWT_ISSUER = 'odoo'
|
||||
JWT_AUDIENCE = 'odoo-api'
|
||||
|
||||
|
||||
def json_response(payload, status=200):
|
||||
return request.make_response(
|
||||
json.dumps(payload),
|
||||
headers=[('Content-Type', 'application/json')],
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
def get_jwt_secret():
|
||||
secret_key = config.get('jwt_secret')
|
||||
if not secret_key or len(secret_key) < 32:
|
||||
return None
|
||||
return secret_key
|
||||
|
||||
|
||||
def check_jwt_auth(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
auth_header = request.httprequest.headers.get('Authorization', '')
|
||||
|
||||
if not auth_header.startswith('Bearer '):
|
||||
return json_response({'error': 'Bearer token is required.'}, status=401)
|
||||
|
||||
secret_key = get_jwt_secret()
|
||||
if not secret_key:
|
||||
return json_response({'error': 'JWT secret is not configured.'}, status=500)
|
||||
|
||||
token = auth_header.split(' ', 1)[1].strip()
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
secret_key,
|
||||
algorithms=['HS256'],
|
||||
issuer=JWT_ISSUER,
|
||||
audience=JWT_AUDIENCE,
|
||||
options={'require': ['exp', 'iat', 'sub', 'client_id']},
|
||||
)
|
||||
user_id = int(payload['sub'])
|
||||
client = request.env['api.client'].sudo().search([
|
||||
('client_id', '=', payload['client_id']),
|
||||
('active', '=', True),
|
||||
('user_id', '=', user_id),
|
||||
], limit=1)
|
||||
user = request.env['res.users'].sudo().browse(user_id).exists()
|
||||
if not client or not user or not user.active:
|
||||
return json_response({'error': 'Token subject is not allowed.'}, status=401)
|
||||
|
||||
request.jwt_payload = payload
|
||||
request.jwt_client = client
|
||||
request.jwt_user_id = user_id
|
||||
request.update_env(user=user_id)
|
||||
except jwt.ExpiredSignatureError:
|
||||
return json_response({'error': 'Token has expired.'}, status=401)
|
||||
except (jwt.InvalidTokenError, ValueError):
|
||||
return json_response({'error': 'Token is invalid.'}, status=401)
|
||||
|
||||
return func(self, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
class JwtAuthController(http.Controller):
|
||||
|
||||
@http.route('/api/auth/token', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
def generate_token(self, **kwargs):
|
||||
data = request.httprequest.get_json(silent=True) or kwargs
|
||||
client_id = data.get('client_id')
|
||||
client_secret = data.get('client_secret') or data.get('secret_key')
|
||||
|
||||
if not client_id or not client_secret:
|
||||
return json_response({'error': 'client_id and client_secret are required.'}, status=400)
|
||||
|
||||
secret_key = get_jwt_secret()
|
||||
if not secret_key:
|
||||
return json_response({'error': 'JWT secret is not configured.'}, status=500)
|
||||
|
||||
client = request.env['api.client'].sudo().search([
|
||||
('client_id', '=', client_id),
|
||||
('active', '=', True),
|
||||
], limit=1)
|
||||
|
||||
if not client or not client._check_secret(client_secret) or not client.user_id.active:
|
||||
return json_response({'error': 'Invalid credentials.'}, status=401)
|
||||
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
expires_at = now + datetime.timedelta(seconds=client.token_ttl)
|
||||
payload = {
|
||||
'iss': JWT_ISSUER,
|
||||
'aud': JWT_AUDIENCE,
|
||||
'exp': expires_at,
|
||||
'iat': now,
|
||||
'jti': str(uuid.uuid4()),
|
||||
'sub': str(client.user_id.id),
|
||||
'client_id': client.client_id,
|
||||
}
|
||||
token = jwt.encode(payload, secret_key, algorithm='HS256')
|
||||
client.last_used = fields.Datetime.now()
|
||||
|
||||
return json_response({
|
||||
'access_token': token,
|
||||
'expires_in': client.token_ttl,
|
||||
'token_type': 'Bearer',
|
||||
})
|
||||
|
||||
@http.route('/api/ifc/projects', type='http', auth='public', methods=['GET', 'POST'], csrf=False)
|
||||
@check_jwt_auth
|
||||
def get_ifc_projects(self, **kwargs):
|
||||
return json_response({
|
||||
'message': 'JWT token is valid.',
|
||||
'user_id': request.env.user.id,
|
||||
'user_name': request.env.user.name,
|
||||
'client_id': request.jwt_payload.get('client_id'),
|
||||
'data': ['Proyek_IFC_Gedung_A', 'Proyek_IFC_Infrastruktur_B'],
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
from . import api_client
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,113 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools.misc import consteq
|
||||
|
||||
|
||||
class ApiClient(models.Model):
|
||||
_name = 'api.client'
|
||||
_description = 'API Client for JWT Authentication'
|
||||
_rec_name = 'name'
|
||||
|
||||
name = fields.Char(string='Application Name', required=True)
|
||||
active = fields.Boolean(default=True)
|
||||
client_id = fields.Char(
|
||||
string='Client ID',
|
||||
required=True,
|
||||
default=lambda self: secrets.token_urlsafe(24),
|
||||
copy=False,
|
||||
readonly=True,
|
||||
)
|
||||
client_secret = fields.Char(
|
||||
string='New Client Secret',
|
||||
copy=False,
|
||||
store=False,
|
||||
help='Shown only before saving. Use Regenerate Secret to rotate it.',
|
||||
)
|
||||
client_secret_hash = fields.Char(string='Client Secret Hash', copy=False, readonly=True)
|
||||
user_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='Odoo User',
|
||||
required=True,
|
||||
domain=[('active', '=', True)],
|
||||
help='Odoo user mapped to JWT tokens issued for this client.',
|
||||
)
|
||||
token_ttl = fields.Integer(string='Token Lifetime (seconds)', default=7200, required=True)
|
||||
last_used = fields.Datetime(readonly=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('client_id_unique', 'unique(client_id)', 'Client ID must be unique.'),
|
||||
('token_ttl_positive', 'check(token_ttl > 0)', 'Token lifetime must be positive.'),
|
||||
]
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
generated_secrets = []
|
||||
for vals in vals_list:
|
||||
plain_secret = vals.pop('client_secret', None) or self._generate_secret()
|
||||
vals['client_secret_hash'] = self._hash_secret(plain_secret)
|
||||
generated_secrets.append(plain_secret)
|
||||
records = super().create(vals_list)
|
||||
for record, plain_secret in zip(records, generated_secrets):
|
||||
record.client_secret = plain_secret
|
||||
return records
|
||||
|
||||
def write(self, vals):
|
||||
if 'client_secret' in vals:
|
||||
plain_secret = vals.pop('client_secret')
|
||||
if plain_secret:
|
||||
vals['client_secret_hash'] = self._hash_secret(plain_secret)
|
||||
return super().write(vals)
|
||||
|
||||
def action_regenerate_secret(self):
|
||||
for client in self:
|
||||
plain_secret = self._generate_secret()
|
||||
client.write({'client_secret': plain_secret})
|
||||
client.client_secret = plain_secret
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Client secret regenerated',
|
||||
'message': 'Copy the new secret from the form before leaving this page.',
|
||||
'type': 'warning',
|
||||
'sticky': True,
|
||||
},
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _generate_secret(self):
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
@api.model
|
||||
def _hash_secret(self, secret):
|
||||
if not secret:
|
||||
raise UserError('Client secret cannot be empty.')
|
||||
iterations = 260000
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac('sha256', secret.encode(), salt, iterations)
|
||||
return '$'.join((
|
||||
'pbkdf2_sha256',
|
||||
str(iterations),
|
||||
base64.urlsafe_b64encode(salt).decode(),
|
||||
base64.urlsafe_b64encode(digest).decode(),
|
||||
))
|
||||
|
||||
def _check_secret(self, secret):
|
||||
self.ensure_one()
|
||||
try:
|
||||
scheme, iterations, salt, expected = self.client_secret_hash.split('$', 3)
|
||||
if scheme != 'pbkdf2_sha256':
|
||||
return False
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
'sha256',
|
||||
secret.encode(),
|
||||
base64.urlsafe_b64decode(salt.encode()),
|
||||
int(iterations),
|
||||
)
|
||||
return consteq(base64.urlsafe_b64encode(digest).decode(), expected)
|
||||
except Exception:
|
||||
return False
|
||||
@@ -0,0 +1,2 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_api_client_system,api.client system,model_api_client,base.group_system,1,1,1,1
|
||||
|
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_api_client_tree" model="ir.ui.view">
|
||||
<field name="name">api.client.tree</field>
|
||||
<field name="model">api.client</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="API Clients">
|
||||
<field name="name"/>
|
||||
<field name="client_id"/>
|
||||
<field name="user_id"/>
|
||||
<field name="active"/>
|
||||
<field name="last_used"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_api_client_form" model="ir.ui.view">
|
||||
<field name="name">api.client.form</field>
|
||||
<field name="model">api.client</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="API Client">
|
||||
<header>
|
||||
<button name="action_regenerate_secret" type="object" string="Regenerate Secret" class="btn-primary"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="active"/>
|
||||
<field name="user_id"/>
|
||||
<field name="token_ttl"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="client_id"/>
|
||||
<field name="client_secret" password="True" placeholder="Shown only when generated or rotated"/>
|
||||
<field name="last_used"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_api_client" model="ir.actions.act_window">
|
||||
<field name="name">API Clients (JWT)</field>
|
||||
<field name="res_model">api.client</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_api_client_root" name="JWT Auth" action="action_api_client" sequence="100" web_icon="grt_jwt_token,static/description/icon.png"/>
|
||||
|
||||
<record id="menu_api_client_root" model="ir.ui.menu">
|
||||
<field name="parent_id" eval="False"/>
|
||||
<field name="group_ids" eval="[(4, ref('base.group_system'))]"/>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user