mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-02-12 08:02:44 +00:00
add import authorized oauth
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
This commit is contained in:
@@ -194,6 +194,8 @@ const (
|
||||
ROUTE_V1_OAUTH_PROVIDER_REMOVE_AUTH = "/api/v1/oauth-provider/:id/remove-authorization"
|
||||
ROUTE_V1_OAUTH_AUTHORIZE = "/api/v1/oauth-authorize/:id"
|
||||
ROUTE_V1_OAUTH_CALLBACK = "/api/v1/oauth-callback"
|
||||
ROUTE_V1_OAUTH_IMPORT_TOKENS = "/api/v1/oauth-provider/import-tokens"
|
||||
ROUTE_V1_OAUTH_EXPORT_TOKENS = "/api/v1/oauth-provider/:id/export-tokens"
|
||||
// license
|
||||
ROUTE_V1_LICENSE = "/api/v1/license"
|
||||
// version
|
||||
@@ -377,6 +379,8 @@ func setupRoutes(
|
||||
POST(ROUTE_V1_OAUTH_PROVIDER_REMOVE_AUTH, middleware.SessionHandler, controllers.OAuthProvider.RemoveAuthorization).
|
||||
GET(ROUTE_V1_OAUTH_AUTHORIZE, middleware.SessionHandler, controllers.OAuthProvider.GetAuthorizationURL).
|
||||
GET(ROUTE_V1_OAUTH_CALLBACK, controllers.OAuthProvider.HandleCallback).
|
||||
POST(ROUTE_V1_OAUTH_IMPORT_TOKENS, middleware.SessionHandler, controllers.OAuthProvider.ImportAuthorizedTokens).
|
||||
GET(ROUTE_V1_OAUTH_EXPORT_TOKENS, middleware.SessionHandler, controllers.OAuthProvider.ExportAuthorizedTokens).
|
||||
// emails
|
||||
GET(ROUTE_V1_EMAIL, middleware.SessionHandler, controllers.Email.GetAll).
|
||||
GET(ROUTE_V1_EMAIL_OVERVIEW, middleware.SessionHandler, controllers.Email.GetOverviews).
|
||||
|
||||
@@ -372,3 +372,61 @@ func (c *OAuthProvider) renderCallbackPage(g *gin.Context, success bool, errorCo
|
||||
g.Header("Content-Type", "text/html; charset=utf-8")
|
||||
g.String(http.StatusOK, html)
|
||||
}
|
||||
|
||||
// ImportAuthorizedTokens imports pre-authorized oauth tokens
|
||||
func (c *OAuthProvider) ImportAuthorizedTokens(g *gin.Context) {
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse request
|
||||
var req []model.ImportAuthorizedToken
|
||||
if ok := c.handleParseRequest(g, &req); !ok {
|
||||
return
|
||||
}
|
||||
// import tokens
|
||||
ids, err := c.OAuthProviderService.ImportAuthorizedTokens(
|
||||
g.Request.Context(),
|
||||
session,
|
||||
req,
|
||||
)
|
||||
// handle response
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// convert ids to strings
|
||||
idStrings := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
idStrings[i] = id.String()
|
||||
}
|
||||
|
||||
c.Response.OK(g, gin.H{
|
||||
"ids": idStrings,
|
||||
"count": len(ids),
|
||||
})
|
||||
}
|
||||
|
||||
// ExportAuthorizedTokens exports oauth tokens in the import format
|
||||
func (c *OAuthProvider) ExportAuthorizedTokens(g *gin.Context) {
|
||||
session, _, ok := c.handleSession(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// parse id
|
||||
id, ok := c.handleParseIDParam(g)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// export tokens
|
||||
exported, err := c.OAuthProviderService.ExportAuthorizedTokens(
|
||||
g.Request.Context(),
|
||||
session,
|
||||
*id,
|
||||
)
|
||||
// handle response
|
||||
if ok := c.handleErrors(g, err); !ok {
|
||||
return
|
||||
}
|
||||
c.Response.OK(g, exported)
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@ type OAuthProvider struct {
|
||||
// status
|
||||
IsAuthorized bool `gorm:"not null;default:false;"`
|
||||
|
||||
// indicates if this provider was created via import (with pre-authorized tokens)
|
||||
// imported providers cannot be authorized/reauthorized via oauth flow
|
||||
IsImported bool `gorm:"not null;default:false;"`
|
||||
|
||||
// can belong-to
|
||||
CompanyID *uuid.UUID `gorm:"uniqueIndex:idx_oauth_providers_unique_name_and_company_id;"`
|
||||
Company *Company `gorm:"foreignkey:CompanyID;"`
|
||||
|
||||
50
backend/model/importAuthorizedToken.go
Normal file
50
backend/model/importAuthorizedToken.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"github.com/go-errors/errors"
|
||||
"github.com/phishingclub/phishingclub/validate"
|
||||
)
|
||||
|
||||
// ImportAuthorizedToken represents an imported oauth token
|
||||
type ImportAuthorizedToken struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ClientID string `json:"client_id"`
|
||||
ExpiresAt int64 `json:"expires_at"` // unix timestamp in milliseconds
|
||||
Name string `json:"name"`
|
||||
User string `json:"user"`
|
||||
Scope string `json:"scope"`
|
||||
TokenURL string `json:"token_url,omitempty"`
|
||||
CreatedAt int64 `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
// Validate checks if the imported token has a valid state
|
||||
func (i *ImportAuthorizedToken) Validate() error {
|
||||
if i.AccessToken == "" {
|
||||
return validate.WrapErrorWithField(errors.New("is required"), "access_token")
|
||||
}
|
||||
if i.RefreshToken == "" {
|
||||
return validate.WrapErrorWithField(errors.New("is required"), "refresh_token")
|
||||
}
|
||||
if i.Name == "" {
|
||||
return validate.WrapErrorWithField(errors.New("is required"), "name")
|
||||
}
|
||||
if i.ExpiresAt == 0 {
|
||||
return validate.WrapErrorWithField(errors.New("is required"), "expires_at")
|
||||
}
|
||||
if i.ClientID == "" {
|
||||
return validate.WrapErrorWithField(errors.New("is required"), "client_id")
|
||||
}
|
||||
if i.Scope == "" {
|
||||
return validate.WrapErrorWithField(errors.New("is required"), "scope")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetDefaultTokenURL sets the default token url if not provided
|
||||
func (i *ImportAuthorizedToken) SetDefaultTokenURL() {
|
||||
if i.TokenURL == "" {
|
||||
// default to microsoft token url (most common use case)
|
||||
i.TokenURL = "https://login.microsoftonline.com/73582fc0-9e0a-459e-aba7-84eb896f9a3f/oauth2/v2.0/token"
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,10 @@ type OAuthProvider struct {
|
||||
// status
|
||||
IsAuthorized nullable.Nullable[bool] `json:"isAuthorized"` // whether oauth flow completed
|
||||
|
||||
// indicates if this provider was created via import (with pre-authorized tokens)
|
||||
// imported providers cannot be authorized/reauthorized via oauth flow
|
||||
IsImported nullable.Nullable[bool] `json:"isImported"`
|
||||
|
||||
CompanyID nullable.Nullable[uuid.UUID] `json:"companyID"`
|
||||
Company *Company `json:"company"`
|
||||
}
|
||||
@@ -175,5 +179,12 @@ func (o *OAuthProvider) ToDBMap() map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
if o.IsImported.IsSpecified() {
|
||||
m["is_imported"] = nil
|
||||
if isImported, err := o.IsImported.Get(); err == nil {
|
||||
m["is_imported"] = isImported
|
||||
}
|
||||
}
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -226,6 +226,7 @@ func ToOAuthProvider(row *database.OAuthProvider) *model.OAuthProvider {
|
||||
refreshToken := nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(row.RefreshToken))
|
||||
authorizedEmail := nullable.NewNullableWithValue(*vo.NewOptionalString255Must(row.AuthorizedEmail))
|
||||
isAuthorized := nullable.NewNullableWithValue(row.IsAuthorized)
|
||||
isImported := nullable.NewNullableWithValue(row.IsImported)
|
||||
|
||||
return &model.OAuthProvider{
|
||||
ID: id,
|
||||
@@ -244,6 +245,7 @@ func ToOAuthProvider(row *database.OAuthProvider) *model.OAuthProvider {
|
||||
AuthorizedEmail: authorizedEmail,
|
||||
AuthorizedAt: row.AuthorizedAt,
|
||||
IsAuthorized: isAuthorized,
|
||||
IsImported: isImported,
|
||||
Company: nil,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,20 @@ func (o *OAuthProvider) UpdateByID(
|
||||
return errs.Wrap(err)
|
||||
}
|
||||
|
||||
// for imported providers, only allow name updates
|
||||
if existing.IsImported.MustGet() {
|
||||
// clear all fields except name and id
|
||||
provider.AuthURL = nullable.NewNullNullable[vo.String512]()
|
||||
provider.TokenURL = nullable.NewNullNullable[vo.String512]()
|
||||
provider.Scopes = nullable.NewNullNullable[vo.String512]()
|
||||
provider.ClientID = nullable.NewNullNullable[vo.String255]()
|
||||
provider.ClientSecret = nullable.NewNullNullable[vo.OptionalString255]()
|
||||
provider.AccessToken = nullable.NewNullNullable[vo.OptionalString1MB]()
|
||||
provider.RefreshToken = nullable.NewNullNullable[vo.OptionalString1MB]()
|
||||
provider.IsAuthorized = nullable.NewNullNullable[bool]()
|
||||
provider.IsImported = nullable.NewNullNullable[bool]()
|
||||
}
|
||||
|
||||
var companyID *uuid.UUID
|
||||
if cid, err := existing.CompanyID.Get(); err == nil {
|
||||
companyID = &cid
|
||||
@@ -366,6 +380,11 @@ func (o *OAuthProvider) GetAuthorizationURL(
|
||||
return "", errs.Wrap(err)
|
||||
}
|
||||
|
||||
// prevent authorization on imported providers
|
||||
if provider.IsImported.MustGet() {
|
||||
return "", errors.New("cannot authorize imported providers - they use pre-authorized tokens")
|
||||
}
|
||||
|
||||
// generate cryptographically random state token (32 bytes base64-encoded)
|
||||
stateToken, err := random.GenerateRandomURLBase64Encoded(32)
|
||||
if err != nil {
|
||||
@@ -643,6 +662,179 @@ func (o *OAuthProvider) requestTokens(tokenURL string, data url.Values) (*TokenR
|
||||
return &tokens, nil
|
||||
}
|
||||
|
||||
// ImportAuthorizedTokens imports pre-authorized oauth tokens
|
||||
func (o *OAuthProvider) ImportAuthorizedTokens(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
tokens []model.ImportAuthorizedToken,
|
||||
) ([]uuid.UUID, error) {
|
||||
ae := NewAuditEvent("OAuthProvider.ImportAuthorizedTokens", session)
|
||||
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
o.LogAuthError(err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
o.AuditLogNotAuthorized(ae)
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
// validate input
|
||||
if len(tokens) == 0 {
|
||||
return nil, errors.New("no tokens provided")
|
||||
}
|
||||
|
||||
var ids []uuid.UUID
|
||||
|
||||
for _, token := range tokens {
|
||||
// validate token
|
||||
if err := token.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// set default token url if not provided
|
||||
token.SetDefaultTokenURL()
|
||||
|
||||
// convert expires_at from milliseconds to time
|
||||
expiresAt := time.UnixMilli(token.ExpiresAt)
|
||||
|
||||
// create provider with imported flag
|
||||
provider := &model.OAuthProvider{
|
||||
Name: nullable.NewNullableWithValue(*vo.NewString127Must(token.Name)),
|
||||
AuthURL: nullable.NewNullableWithValue(*vo.NewString512Must("n/a")), // placeholder for imported
|
||||
TokenURL: nullable.NewNullableWithValue(*vo.NewString512Must(token.TokenURL)),
|
||||
Scopes: nullable.NewNullableWithValue(*vo.NewString512Must(token.Scope)),
|
||||
ClientID: nullable.NewNullableWithValue(*vo.NewString255Must(token.ClientID)),
|
||||
ClientSecret: nullable.NewNullableWithValue(*vo.NewOptionalString255Must("n/a")), // placeholder for imported
|
||||
AccessToken: nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(token.AccessToken)),
|
||||
RefreshToken: nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(token.RefreshToken)),
|
||||
TokenExpiresAt: &expiresAt,
|
||||
AuthorizedEmail: nullable.NewNullableWithValue(*vo.NewOptionalString255Must(token.User)),
|
||||
AuthorizedAt: ptrTime(time.Now()),
|
||||
IsAuthorized: nullable.NewNullableWithValue(true),
|
||||
IsImported: nullable.NewNullableWithValue(true),
|
||||
CompanyID: nullable.NewNullNullable[uuid.UUID](),
|
||||
}
|
||||
|
||||
// check uniqueness
|
||||
isOK, err := repository.CheckNameIsUnique(
|
||||
ctx,
|
||||
o.OAuthProviderRepository.DB,
|
||||
"oauth_providers",
|
||||
token.Name,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
o.Logger.Errorw("failed to check oauth provider uniqueness", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isOK {
|
||||
o.Logger.Debugw("oauth provider name is already used", "name", token.Name)
|
||||
return nil, validate.WrapErrorWithField(errors.New("is not unique"), "name")
|
||||
}
|
||||
|
||||
// save
|
||||
id, err := o.OAuthProviderRepository.Insert(ctx, provider)
|
||||
if err != nil {
|
||||
o.Logger.Errorw("failed to insert imported oauth provider", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
|
||||
ids = append(ids, *id)
|
||||
}
|
||||
|
||||
ae.Details["count"] = len(ids)
|
||||
o.AuditLogAuthorized(ae)
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// ExportAuthorizedTokens exports oauth tokens in the import format
|
||||
func (o *OAuthProvider) ExportAuthorizedTokens(
|
||||
ctx context.Context,
|
||||
session *model.Session,
|
||||
providerID uuid.UUID,
|
||||
) (*model.ImportAuthorizedToken, error) {
|
||||
ae := NewAuditEvent("OAuthProvider.ExportAuthorizedTokens", session)
|
||||
|
||||
// check permissions
|
||||
isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL)
|
||||
if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) {
|
||||
o.LogAuthError(err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
if !isAuthorized {
|
||||
o.AuditLogNotAuthorized(ae)
|
||||
return nil, errs.ErrAuthorizationFailed
|
||||
}
|
||||
|
||||
// get provider
|
||||
provider, err := o.OAuthProviderRepository.GetByID(ctx, providerID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
o.Logger.Errorw("failed to get oauth provider", "error", err)
|
||||
return nil, errs.Wrap(err)
|
||||
}
|
||||
|
||||
// check if provider is authorized
|
||||
if !provider.IsAuthorized.MustGet() {
|
||||
return nil, errors.New("provider is not authorized")
|
||||
}
|
||||
|
||||
// extract tokens
|
||||
accessToken := ""
|
||||
if at, err := provider.AccessToken.Get(); err == nil {
|
||||
accessToken = at.String()
|
||||
}
|
||||
|
||||
refreshToken := ""
|
||||
if rt, err := provider.RefreshToken.Get(); err == nil {
|
||||
refreshToken = rt.String()
|
||||
}
|
||||
|
||||
authorizedEmail := ""
|
||||
if ae, err := provider.AuthorizedEmail.Get(); err == nil {
|
||||
authorizedEmail = ae.String()
|
||||
}
|
||||
|
||||
var expiresAt int64
|
||||
if provider.TokenExpiresAt != nil {
|
||||
expiresAt = provider.TokenExpiresAt.UnixMilli()
|
||||
}
|
||||
|
||||
var createdAt int64
|
||||
if provider.CreatedAt != nil {
|
||||
createdAt = provider.CreatedAt.UnixMilli()
|
||||
}
|
||||
|
||||
exported := &model.ImportAuthorizedToken{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ClientID: provider.ClientID.MustGet().String(),
|
||||
ExpiresAt: expiresAt,
|
||||
Name: provider.Name.MustGet().String(),
|
||||
User: authorizedEmail,
|
||||
Scope: provider.Scopes.MustGet().String(),
|
||||
TokenURL: provider.TokenURL.MustGet().String(),
|
||||
CreatedAt: createdAt,
|
||||
}
|
||||
|
||||
ae.Details["id"] = providerID.String()
|
||||
o.AuditLogAuthorized(ae)
|
||||
|
||||
return exported, nil
|
||||
}
|
||||
|
||||
// ptrTime returns a pointer to a time.Time
|
||||
func ptrTime(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
|
||||
/* @TODO the logic is here, but i dont think we really need to implement it
|
||||
// CleanupExpiredStates removes expired oauth state tokens from database
|
||||
// should be called periodically (e.g., daily)
|
||||
|
||||
183
examples/oauth-token-import.md
Normal file
183
examples/oauth-token-import.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# OAuth Token Import
|
||||
|
||||
This guide explains how to import existing OAuth access and refresh tokens into PhishingClub.
|
||||
|
||||
## Overview
|
||||
|
||||
You may want to import existing OAuth tokens if you:
|
||||
- Already have valid tokens from a previous OAuth authorization
|
||||
- Want to migrate tokens from another system
|
||||
- Have tokens generated through a different OAuth flow
|
||||
- Need to use tokens that were authorized outside of PhishingClub
|
||||
|
||||
## Requirements
|
||||
|
||||
Before importing tokens, you need:
|
||||
1. Valid access and refresh tokens from an OAuth provider
|
||||
2. The token expiration timestamp (in milliseconds)
|
||||
3. The client ID associated with the tokens
|
||||
4. The token URL for refreshing tokens
|
||||
|
||||
## Import Format
|
||||
|
||||
Tokens must be provided as a JSON array. Each token object should have the following fields:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"access_token": "eyJ0eXAiOiJKV1QiLCJub25jZSI6...",
|
||||
"refresh_token": "1.AXkAwC9YcwqenkWrp4TriW...",
|
||||
"client_id": "1fec8e78-bce4-4aaf-ab1b-5451cc387264",
|
||||
"expires_at": 1765657989704,
|
||||
"name": "user@example.com (Microsoft Teams)",
|
||||
"user": "user@example.com",
|
||||
"scope": "https://graph.microsoft.com/.default offline_access",
|
||||
"token_url": "https://login.microsoftonline.com/73582fc0-9e0a-459e-aba7-84eb896f9a3f/oauth2/v2.0/token",
|
||||
"created_at": 1765634409156
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Field Descriptions
|
||||
|
||||
- **access_token** (required): The OAuth access token (typically a JWT)
|
||||
- **refresh_token** (required): The OAuth refresh token used to get new access tokens
|
||||
- **client_id** (required): The OAuth application's client ID
|
||||
- **expires_at** (required): Unix timestamp in milliseconds when the access token expires
|
||||
- **name** (required): A display name for this token (e.g., "user@example.com (Microsoft Teams)")
|
||||
- **user** (required): The email or username of the authorized account
|
||||
- **scope** (required): Space-separated list of OAuth scopes
|
||||
- **token_url** (optional): The token endpoint URL. Defaults to Microsoft's token endpoint if not provided
|
||||
- **created_at** (optional): Unix timestamp in milliseconds when the token was created
|
||||
|
||||
## Importing Tokens via UI
|
||||
|
||||
1. Navigate to the **OAuth** page in PhishingClub
|
||||
2. Click the **Import Authorized Token** button
|
||||
3. Paste your JSON array into the text field
|
||||
4. Click **Submit**
|
||||
|
||||
The system will validate and import all tokens in the array. Each token becomes a separate OAuth provider entry.
|
||||
|
||||
## Importing Tokens via API
|
||||
|
||||
You can also import tokens programmatically using the API:
|
||||
|
||||
```bash
|
||||
POST /api/v1/oauth-provider/import-tokens
|
||||
Content-Type: application/json
|
||||
|
||||
[
|
||||
{
|
||||
"access_token": "eyJ0eXAiOiJKV1QiLCJub25jZSI6...",
|
||||
"refresh_token": "1.AXkAwC9YcwqenkWrp4TriW...",
|
||||
"client_id": "1fec8e78-bce4-4aaf-ab1b-5451cc387264",
|
||||
"expires_at": 1765657989704,
|
||||
"name": "user@example.com (Microsoft Teams)",
|
||||
"user": "user@example.com",
|
||||
"scope": "https://graph.microsoft.com/.default offline_access"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"ids": ["550e8400-e29b-41d4-a716-446655440000"],
|
||||
"count": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Exporting Tokens
|
||||
|
||||
You can export tokens from PhishingClub to the same format used for importing:
|
||||
|
||||
1. Navigate to the **OAuth** page
|
||||
2. Find the provider you want to export
|
||||
3. Click the **ellipsis menu (⋮)** on the provider row
|
||||
4. Select **Export Token**
|
||||
|
||||
The token will be downloaded as a JSON file that can be imported elsewhere.
|
||||
|
||||
### Export via API
|
||||
|
||||
```bash
|
||||
GET /api/v1/oauth-provider/{id}/export-tokens
|
||||
```
|
||||
|
||||
## Imported Provider Restrictions
|
||||
|
||||
OAuth providers created via import have some restrictions compared to regular providers:
|
||||
|
||||
1. **Cannot be authorized or re-authorized** - They use pre-authorized tokens and cannot go through the OAuth flow
|
||||
2. **Limited editing** - Only the name field can be edited. Client credentials and URLs cannot be changed
|
||||
3. **No copying** - Imported providers cannot be copied
|
||||
4. **Token refresh only** - They can only refresh their tokens using the refresh token, not obtain new ones via authorization
|
||||
|
||||
## Microsoft 365 / Teams Example
|
||||
|
||||
If you have Microsoft Teams or Office 365 OAuth tokens, they typically look like this:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"access_token": "eyJ0eXAiOiJKV1QiLCJub25jZSI6ImVCUV9XLUV5d2gtSlpqbXFuWDlUTGF2ZkNIR09kV2U4RFBJM1k5a3FibGciLCJhbGciOiJSUzI1NiIsIng1dCI6InJ0c0ZULWItN0x1WTdEVlllU05LY0lKN1ZuYyIsImtpZCI6InJ0c0ZULWItN0x1WTdEVlllU05LY0lKN1ZuYyJ9...",
|
||||
"refresh_token": "1.AXkAwC9YcwqenkWrp4TriW-aP3iO7B_kvK9KqxtUUcw4cmR5AKd5AA.BQABAwEAAAADAOz_BQD0_zaTKAHXsCeupQqQPHKcMtLP8K45KxFiwFgAFG5-NCiSMh30e3jgmHbuuFBYk8qWOaMLqmrh_jfA5biRz6pm7w6zmcD4HbpDCUeQ2eZ0UHAl4aeZbp5FYlSrBgozbdLkgDnSXjKOSeHTVprpbOpe94rzqapKwLvUNjHPQvSiRYyOlh94chh-DTEWSYGUK1EA0bShHa51ZfLZOIeLkeDzqieuSt7b4eqBVvkTLArtAHceN0V9rbLTfMAg18usGY6vdZEwOWwAjayuT-xZPSKdTuqeN6CZ1BHKBj7fhmy48jiXyQolgAL6eXMUnjPC_FsUplJn3guYYXmzTh3B6PHJn6EQjuKWGR9Iaaw4TGB0qmwfJJBfsFNv25EKUR8ragrHE-tTvp9fDuRzTMgW9-_kmngdz95W_ob1RaxN6gsLpZ1O1y75dJkFnoQGLy05YdTAQzo9fdddwHR2NMbe7ovXw440hPqb_ValzBY1ovsMSrr2QSFM1VZDdkWKDyj9JBgzd3XVTWLgcUXpxnou_bM2ZHs2EiRQx9FiFCscTAHg7iFjcMIzA1tFUTunKYtcHN3m6-XGiPUQp2g3Zwu8fEnYo_dG10Ci3uw2PkxmsrVHAT3btlY9QnyGzgUQzfR_Cg9mEmXr476NQE6_NqlBPjZRho2klvolBgAthXtyZPKM1vhL-ei7AcMico9_06DBh-g1uavfQ9LtBLG_RCXfKqU2bTsN0KFp4AhTd49jvGLVPw_bFcQ1DzZHLYiQLv5lZqO6ATZiKJHY9FO3Twpnj7dKxqaxytXlBQ3lmxB5MxjRJumK8lvtlF21-MXn5HYymliU1okLsgHIs-lN-NOVvEMQ2tpt-pJ_XEp8l6baMzjcdILaJ9HkiNPhGoWZ3qkv7k_DKpybkZLX6On9KamfaBP-CKcopBbqvHomuZUItm_6MAdMJBuMFypszop4v_rXq1PC3XYlbDcfwiyHoQen79CgX6xap_31UqeYCaTvazQwFlho-Y3CheTfbDRjRJqfxSPmQDZkZugwRlIICDRlId8GogPqH4a_DCv5N3pbmu-lTNJ0YibCYbEanQxyot_UTQGWCRulUIQ28o8Y5wMn9UoBAzD9opT4g1XW3WnAogU4mdOwO40hhXMraW4Cjd5JQOvj-HUmxlwCHP-PiyhHn9w_WDlqdkigJerw3t12L8wZwC77yipee8JGbZFiIoNYW462wtpkUBkqSfsI2D5O-MgeriY5kYHbCz5e-I5g4pwRj54_mwQUkc61OHkf0rXyiVhH8zsmrCvkWov6gPmf9259_QEZ",
|
||||
"client_id": "1fec8e78-bce4-4aaf-ab1b-5451cc387264",
|
||||
"expires_at": 1765657989704,
|
||||
"name": "ronni@365.skansing.dk (Microsoft Teams)",
|
||||
"user": "ronni@365.skansing.dk",
|
||||
"scope": "https://graph.microsoft.com/.default offline_access"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Microsoft OAuth Endpoints
|
||||
|
||||
For Microsoft 365/Teams OAuth:
|
||||
|
||||
**Authorization URL:**
|
||||
```
|
||||
https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize
|
||||
```
|
||||
|
||||
**Token URL:**
|
||||
```
|
||||
https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/token
|
||||
```
|
||||
|
||||
Replace `{tenant-id}` with your Azure AD tenant ID.
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Invalid Token Format
|
||||
Ensure your JSON is properly formatted and all required fields are present.
|
||||
|
||||
### Expired Tokens
|
||||
The refresh token is invalid or has expired. You need to perform a new OAuth authorization flow.
|
||||
|
||||
### Token Refresh Failures
|
||||
If token refresh fails, check that:
|
||||
- The token URL is correct
|
||||
- The client ID matches the one used to obtain the tokens
|
||||
- The refresh token hasn't been revoked
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Secure Storage**: Imported tokens are stored encrypted in the database, just like regular OAuth tokens.
|
||||
2. **Access Control**: Only users with global permissions can import and export tokens.
|
||||
3. **Token Rotation**: Tokens will be automatically refreshed when they expire using the refresh token.
|
||||
4. **Audit Trail**: All import and export operations are logged in the audit log.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter issues importing tokens:
|
||||
|
||||
1. Verify the JSON format is correct (use a JSON validator)
|
||||
2. Check that all required fields are present
|
||||
3. Ensure the expires_at timestamp is in milliseconds, not seconds
|
||||
4. Verify the token_url is accessible and correct
|
||||
5. Check the application logs for detailed error messages
|
||||
@@ -1905,6 +1905,26 @@ export class API {
|
||||
*/
|
||||
removeAuthorization: async (id) => {
|
||||
return await postJSON(this.getPath(`/oauth-provider/${id}/remove-authorization`), {});
|
||||
},
|
||||
|
||||
/**
|
||||
* Import authorized OAuth tokens.
|
||||
*
|
||||
* @param {Array<{access_token: string, refresh_token: string, client_id: string, expires_at: number, name: string, user: string, scope: string, token_url?: string, created_at?: number}>} tokens
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
importTokens: async (tokens) => {
|
||||
return await postJSON(this.getPath('/oauth-provider/import-tokens'), tokens);
|
||||
},
|
||||
|
||||
/**
|
||||
* Export OAuth tokens for a provider.
|
||||
*
|
||||
* @param {string} id
|
||||
* @returns {Promise<ApiResponse>}
|
||||
*/
|
||||
exportTokens: async (id) => {
|
||||
return await getJSON(this.getPath(`/oauth-provider/${id}/export-tokens`));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -68,6 +68,12 @@
|
||||
id: null,
|
||||
name: null
|
||||
};
|
||||
let isImportModalVisible = false;
|
||||
let importTokensText = '';
|
||||
let importFormError = '';
|
||||
let isExportModalVisible = false;
|
||||
let exportTokensText = '';
|
||||
let exportTokenExpiry = '';
|
||||
|
||||
$: {
|
||||
modalText = getModalText('OAuth', modalMode);
|
||||
@@ -279,6 +285,92 @@
|
||||
isModalVisible = true;
|
||||
};
|
||||
|
||||
const openImportModal = () => {
|
||||
importTokensText = '';
|
||||
importFormError = '';
|
||||
isImportModalVisible = true;
|
||||
};
|
||||
|
||||
const closeImportModal = () => {
|
||||
isImportModalVisible = false;
|
||||
importTokensText = '';
|
||||
importFormError = '';
|
||||
};
|
||||
|
||||
const onClickImport = async () => {
|
||||
importFormError = '';
|
||||
try {
|
||||
isSubmitting = true;
|
||||
const tokens = JSON.parse(importTokensText);
|
||||
if (!Array.isArray(tokens)) {
|
||||
importFormError = 'Input must be an array of tokens';
|
||||
return;
|
||||
}
|
||||
const res = await api.oauthProvider.importTokens(tokens);
|
||||
if (!res.success) {
|
||||
importFormError = res.error;
|
||||
return;
|
||||
}
|
||||
addToast(`Imported ${res.data.count} OAuth token(s)`, 'Success');
|
||||
closeImportModal();
|
||||
refreshProviders();
|
||||
} catch (err) {
|
||||
if (err instanceof SyntaxError) {
|
||||
importFormError = 'Invalid JSON format';
|
||||
} else {
|
||||
importFormError = 'Failed to import tokens';
|
||||
console.error('failed to import tokens:', err);
|
||||
}
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onClickExport = async (id) => {
|
||||
try {
|
||||
showIsLoading();
|
||||
const res = await api.oauthProvider.exportTokens(id);
|
||||
if (res.success) {
|
||||
// format as array for consistency with import format
|
||||
exportTokensText = JSON.stringify([res.data], null, 2);
|
||||
|
||||
// calculate expiry date
|
||||
if (res.data.expires_at) {
|
||||
const expiryDate = new Date(res.data.expires_at);
|
||||
exportTokenExpiry = expiryDate.toLocaleString();
|
||||
} else {
|
||||
exportTokenExpiry = 'Unknown';
|
||||
}
|
||||
|
||||
isExportModalVisible = true;
|
||||
} else {
|
||||
throw res.error;
|
||||
}
|
||||
} catch (e) {
|
||||
addToast('Failed to export OAuth token', 'Error');
|
||||
console.error('failed to export oauth token', e);
|
||||
} finally {
|
||||
hideIsLoading();
|
||||
}
|
||||
};
|
||||
|
||||
const closeExportModal = () => {
|
||||
isExportModalVisible = false;
|
||||
exportTokensText = '';
|
||||
exportTokenExpiry = '';
|
||||
};
|
||||
|
||||
const onClickCopyExport = () => {
|
||||
navigator.clipboard
|
||||
.writeText(exportTokensText)
|
||||
.then(() => {
|
||||
addToast('Copied to clipboard', 'Success');
|
||||
})
|
||||
.catch(() => {
|
||||
addToast('Failed to copy to clipboard', 'Error');
|
||||
});
|
||||
};
|
||||
|
||||
const openUpdateModal = async (id) => {
|
||||
modalMode = 'update';
|
||||
formError = '';
|
||||
@@ -294,7 +386,8 @@
|
||||
authURL: provider.authURL,
|
||||
tokenURL: provider.tokenURL,
|
||||
scopes: provider.scopes,
|
||||
companyID: provider.companyID
|
||||
companyID: provider.companyID,
|
||||
isImported: provider.isImported
|
||||
};
|
||||
isModalVisible = true;
|
||||
};
|
||||
@@ -385,7 +478,10 @@
|
||||
<HeadTitle title="OAuth" />
|
||||
<main>
|
||||
<Headline>OAuth</Headline>
|
||||
<BigButton on:click={openCreateModal}>New OAuth</BigButton>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<BigButton on:click={openCreateModal}>New OAuth</BigButton>
|
||||
<BigButton on:click={openImportModal}>Import Token</BigButton>
|
||||
</div>
|
||||
<Table
|
||||
columns={['Name', 'Status']}
|
||||
sortable={['name', 'is_authorized']}
|
||||
@@ -420,39 +516,52 @@
|
||||
<TableCellEmpty />
|
||||
<TableCellAction>
|
||||
<TableDropDownEllipsis>
|
||||
<TableCopyButton
|
||||
title="Copy"
|
||||
on:click={() => openCopyModal(provider.id)}
|
||||
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
|
||||
/>
|
||||
{#if !provider.isImported}
|
||||
<TableCopyButton
|
||||
title="Copy"
|
||||
on:click={() => openCopyModal(provider.id)}
|
||||
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
|
||||
/>
|
||||
{/if}
|
||||
<TableUpdateButton
|
||||
on:click={() => openUpdateModal(provider.id)}
|
||||
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
|
||||
/>
|
||||
{#if !provider.isAuthorized}
|
||||
{#if provider.isAuthorized}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => onClickAuthorize(provider.id)}
|
||||
on:click={() => onClickExport(provider.id)}
|
||||
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
|
||||
>
|
||||
<p class="ml-2 text-left">Authorize</p>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => onClickAuthorize(provider.id)}
|
||||
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
|
||||
>
|
||||
<p class="ml-2 text-left">Re-authorize</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => openRemoveAuthAlert(provider)}
|
||||
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
|
||||
>
|
||||
<p class="ml-2 text-left">Remove Authorization</p>
|
||||
<p class="ml-2 text-left">Read Token</p>
|
||||
</button>
|
||||
{/if}
|
||||
{#if !provider.isImported}
|
||||
{#if !provider.isAuthorized}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => onClickAuthorize(provider.id)}
|
||||
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
|
||||
>
|
||||
<p class="ml-2 text-left">Authorize</p>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => onClickAuthorize(provider.id)}
|
||||
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
|
||||
>
|
||||
<p class="ml-2 text-left">Re-authorize</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => openRemoveAuthAlert(provider)}
|
||||
class="w-full px py-1 text-slate-600 dark:text-gray-200 hover:bg-highlight-blue dark:hover:bg-highlight-blue/50 hover:text-white cursor-pointer text-left transition-colors duration-200"
|
||||
>
|
||||
<p class="ml-2 text-left">Remove Authorization</p>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<TableDeleteButton
|
||||
on:click={() => openDeleteAlert(provider)}
|
||||
{...globalButtonDisabledAttributes(provider, contextCompanyID)}
|
||||
@@ -476,53 +585,59 @@
|
||||
>
|
||||
Name
|
||||
</TextField>
|
||||
<TextField
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={255}
|
||||
bind:value={formValues.clientID}
|
||||
placeholder="your-client-id"
|
||||
>
|
||||
Client ID
|
||||
</TextField>
|
||||
<PasswordField
|
||||
required={modalMode === 'create' || modalMode === 'copy'}
|
||||
minLength={modalMode === 'update' ? 0 : 1}
|
||||
maxLength={255}
|
||||
bind:value={formValues.clientSecret}
|
||||
placeholder={modalMode === 'update'
|
||||
? 'Leave empty to keep existing secret'
|
||||
: 'your-client-secret'}
|
||||
>
|
||||
Client Secret
|
||||
</PasswordField>
|
||||
<TextField
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={512}
|
||||
bind:value={formValues.authURL}
|
||||
placeholder="https://example.com/oauth2/v2/auth"
|
||||
>
|
||||
Authorization URL
|
||||
</TextField>
|
||||
<TextField
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={512}
|
||||
bind:value={formValues.tokenURL}
|
||||
placeholder="https://example.com/oauth2/token"
|
||||
>
|
||||
Token URL
|
||||
</TextField>
|
||||
<TextareaField
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={512}
|
||||
bind:value={formValues.scopes}
|
||||
placeholder="https://example.com/auth/mail.send"
|
||||
height="small"
|
||||
toolTipText="Space-separated list of OAuth scopes">Scopes</TextareaField
|
||||
>
|
||||
{#if modalMode === 'update' && formValues.isImported}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 italic">
|
||||
This is an imported provider. Only the name can be edited.
|
||||
</p>
|
||||
{:else}
|
||||
<TextField
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={255}
|
||||
bind:value={formValues.clientID}
|
||||
placeholder="your-client-id"
|
||||
>
|
||||
Client ID
|
||||
</TextField>
|
||||
<PasswordField
|
||||
required={modalMode === 'create' || modalMode === 'copy'}
|
||||
minLength={modalMode === 'update' ? 0 : 1}
|
||||
maxLength={255}
|
||||
bind:value={formValues.clientSecret}
|
||||
placeholder={modalMode === 'update'
|
||||
? 'Leave empty to keep existing secret'
|
||||
: 'your-client-secret'}
|
||||
>
|
||||
Client Secret
|
||||
</PasswordField>
|
||||
<TextField
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={512}
|
||||
bind:value={formValues.authURL}
|
||||
placeholder="https://example.com/oauth2/v2/auth"
|
||||
>
|
||||
Authorization URL
|
||||
</TextField>
|
||||
<TextField
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={512}
|
||||
bind:value={formValues.tokenURL}
|
||||
placeholder="https://example.com/oauth2/token"
|
||||
>
|
||||
Token URL
|
||||
</TextField>
|
||||
<TextareaField
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={512}
|
||||
bind:value={formValues.scopes}
|
||||
placeholder="https://example.com/auth/mail.send"
|
||||
height="small"
|
||||
toolTipText="Space-separated list of OAuth scopes">Scopes</TextareaField
|
||||
>
|
||||
{/if}
|
||||
</FormColumn>
|
||||
</FormColumns>
|
||||
|
||||
@@ -531,6 +646,156 @@
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
headerText="Import Token"
|
||||
visible={isImportModalVisible}
|
||||
onClose={closeImportModal}
|
||||
{isSubmitting}
|
||||
>
|
||||
<div class="mt-4 min-w-[800px]">
|
||||
<div class="mb-4">
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
Import a pre-authorized OAuth token that was obtained outside of PhishingClub.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-md mb-4">
|
||||
<button
|
||||
type="button"
|
||||
class="text-blue-600 dark:text-white hover:text-blue-800 dark:hover:text-gray-300 font-medium inline-flex items-center gap-2"
|
||||
on:click={() => {
|
||||
navigator.clipboard.writeText(`[
|
||||
{
|
||||
"access_token": "eyJ0eXAiOi...",
|
||||
"refresh_token": "1.AXkAwC...",
|
||||
"client_id": "1fec8e78-bce4-4aaf-ab1b-5451cc387264",
|
||||
"expires_at": 1765657989704,
|
||||
"name": "user@example.com (Microsoft Teams)",
|
||||
"user": "user@example.com",
|
||||
"scope": "https://graph.microsoft.com/.default offline_access",
|
||||
"token_url": "https://login.microsoftonline.com/.../oauth2/v2.0/token"
|
||||
}
|
||||
]`);
|
||||
addToast('Copied format example to clipboard', 'Success');
|
||||
}}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
Copy format example
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="import-token-textarea"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
Token JSON
|
||||
</label>
|
||||
<textarea
|
||||
id="import-token-textarea"
|
||||
bind:value={importTokensText}
|
||||
required
|
||||
placeholder={`[
|
||||
{
|
||||
"access_token": "eyJ0eXAiOi...",
|
||||
"refresh_token": "1.AXkAwC...",
|
||||
"client_id": "1fec8e78-bce4-4aaf-ab1b-5451cc387264",
|
||||
"expires_at": 1765657989704,
|
||||
"name": "user@example.com (Microsoft Teams)",
|
||||
"user": "user@example.com",
|
||||
"scope": "https://graph.microsoft.com/.default offline_access",
|
||||
"token_url": "https://login.microsoftonline.com/.../oauth2/v2.0/token"
|
||||
}
|
||||
]`}
|
||||
class="w-full h-96 px-3 py-2 text-sm font-mono bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:text-gray-200 resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<FormGrid on:submit={onClickImport} {isSubmitting}>
|
||||
<FormColumns>
|
||||
<FormColumn>
|
||||
<!-- Empty form column for structure -->
|
||||
</FormColumn>
|
||||
</FormColumns>
|
||||
|
||||
<FormError message={importFormError} />
|
||||
<FormFooter closeModal={closeImportModal} {isSubmitting} />
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
|
||||
<Modal headerText="Export Token" visible={isExportModalVisible} onClose={closeExportModal}>
|
||||
<div class="mt-4 min-w-[800px]">
|
||||
<!-- Expiration Info Section -->
|
||||
<div class="mb-4">
|
||||
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-300">Token Information</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">
|
||||
<span class="font-medium">Expires at:</span>
|
||||
<span class="text-pc-darkblue dark:text-white font-semibold ml-2"
|
||||
>{exportTokenExpiry}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Token JSON Section -->
|
||||
<div class="bg-gray-50 dark:bg-gray-700 p-4 rounded-md">
|
||||
<div class="mb-3">
|
||||
<label
|
||||
for="export-token-textarea"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
Token JSON
|
||||
</label>
|
||||
<textarea
|
||||
id="export-token-textarea"
|
||||
readonly
|
||||
value={exportTokensText}
|
||||
class="w-full h-96 px-3 py-2 text-sm font-mono bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:text-gray-200 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-blue-600 dark:text-white hover:text-blue-800 dark:hover:text-gray-300 font-medium inline-flex items-center gap-2"
|
||||
on:click={onClickCopyExport}
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
Copy to clipboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<FormGrid on:submit={closeExportModal}>
|
||||
<FormColumns>
|
||||
<FormColumn>
|
||||
<!-- Empty form column for structure -->
|
||||
</FormColumn>
|
||||
</FormColumns>
|
||||
<div
|
||||
class="py-4 row-span-2 col-start-1 col-span-3 border-t-2 border-gray-200 dark:border-gray-700 w-full flex flex-row justify-center items-center sm:justify-center md:justify-center lg:justify-end xl:justify-end 2xl:justify-end bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 transition-colors duration-200"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
on:click={closeExportModal}
|
||||
class="bg-blue-600 hover:bg-blue-500 dark:bg-blue-500 dark:hover:bg-blue-400 text-sm uppercase font-bold px-4 py-2 text-white rounded-md transition-colors duration-200"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</FormGrid>
|
||||
</Modal>
|
||||
|
||||
<DeleteAlert
|
||||
name={deleteValues.name}
|
||||
onClick={() => onClickDelete(deleteValues.id)}
|
||||
|
||||
Reference in New Issue
Block a user