Compare commits

..

No commits in common. "a4ccf12b7a9aa59ba1a190179cc5cc4a1f114758" and "7e334222ee798b80caddc8dadf25d82d2294ce22" have entirely different histories.

11 changed files with 60 additions and 340 deletions

68
.idea/workspace.xml generated
View File

@ -4,13 +4,9 @@
<option name="autoReloadType" value="ALL" /> <option name="autoReloadType" value="ALL" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="3fefb2c4-b6f9-466b-a523-53352e8d6f95" name="更改" comment=":sparkles: Reset password APIs"> <list default="true" id="3fefb2c4-b6f9-466b-a523-53352e8d6f95" name="更改" comment=":bug: Fix API mapping issue">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/internal/server/api/accounts_api.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/internal/server/api/accounts_api.go" afterDir="false" /> <change beforePath="$PROJECT_DIR$/pkg/internal/services/notifications.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/internal/services/notifications.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/internal/server/api/index.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/internal/server/api/index.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pkg/internal/services/accounts.go" beforeDir="false" afterPath="$PROJECT_DIR$/pkg/internal/services/accounts.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/web/src/views/flow/confirm.vue" beforeDir="false" afterPath="$PROJECT_DIR$/web/src/views/flow/confirm.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/web/src/views/flow/password-reset.vue" beforeDir="false" afterPath="$PROJECT_DIR$/web/src/views/flow/password-reset.vue" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -45,48 +41,48 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"DefaultGoTemplateProperty": "Go File", &quot;DefaultGoTemplateProperty&quot;: &quot;Go File&quot;,
"Go Build.Backend.executor": "Run", &quot;Go Build.Backend.executor&quot;: &quot;Run&quot;,
"Go 构建.Backend.executor": "Run", &quot;Go 构建.Backend.executor&quot;: &quot;Run&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.go.formatter.settings.were.checked": "true", &quot;RunOnceActivity.go.formatter.settings.were.checked&quot;: &quot;true&quot;,
"RunOnceActivity.go.migrated.go.modules.settings": "true", &quot;RunOnceActivity.go.migrated.go.modules.settings&quot;: &quot;true&quot;,
"RunOnceActivity.go.modules.automatic.dependencies.download": "true", &quot;RunOnceActivity.go.modules.automatic.dependencies.download&quot;: &quot;true&quot;,
"RunOnceActivity.go.modules.go.list.on.any.changes.was.set": "true", &quot;RunOnceActivity.go.modules.go.list.on.any.changes.was.set&quot;: &quot;true&quot;,
"git-widget-placeholder": "master", &quot;git-widget-placeholder&quot;: &quot;master&quot;,
"go.import.settings.migrated": "true", &quot;go.import.settings.migrated&quot;: &quot;true&quot;,
"go.sdk.automatically.set": "true", &quot;go.sdk.automatically.set&quot;: &quot;true&quot;,
"last_opened_file_path": "/Users/littlesheep/Documents/Projects/Hydrogen/Passport/web/src/views", &quot;last_opened_file_path&quot;: &quot;/Users/littlesheep/Documents/Projects/Hydrogen/Passport/pkg/internal/server/api&quot;,
"node.js.detected.package.eslint": "true", &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
"node.js.selected.package.eslint": "(autodetect)", &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
"nodejs_package_manager_path": "npm", &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
"run.code.analysis.last.selected.profile": "pProject Default", &quot;run.code.analysis.last.selected.profile&quot;: &quot;pProject Default&quot;,
"settings.editor.selected.configurable": "preferences.pluginManager", &quot;settings.editor.selected.configurable&quot;: &quot;preferences.pluginManager&quot;,
"ts.external.directory.path": "/Users/littlesheep/Documents/Projects/Hydrogen/Passport/web/node_modules/typescript/lib", &quot;ts.external.directory.path&quot;: &quot;/Users/littlesheep/Documents/Projects/Hydrogen/Passport/web/node_modules/typescript/lib&quot;,
"vue.rearranger.settings.migration": "true" &quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}, },
"keyToStringList": { &quot;keyToStringList&quot;: {
"DatabaseDriversLRU": [ &quot;DatabaseDriversLRU&quot;: [
"postgresql" &quot;postgresql&quot;
] ]
} }
}]]></component> }</component>
<component name="RecentsManager"> <component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS"> <key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/web/src/views" />
<recent name="$PROJECT_DIR$/pkg/internal/server/api" /> <recent name="$PROJECT_DIR$/pkg/internal/server/api" />
<recent name="$PROJECT_DIR$/web" /> <recent name="$PROJECT_DIR$/web" />
<recent name="$PROJECT_DIR$/pkg/services" /> <recent name="$PROJECT_DIR$/pkg/services" />
<recent name="$PROJECT_DIR$/pkg/server/ui" /> <recent name="$PROJECT_DIR$/pkg/server/ui" />
<recent name="$PROJECT_DIR$/pkg/views/users" />
</key> </key>
<key name="MoveFile.RECENT_KEYS"> <key name="MoveFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/web/src/views/flow" />
<recent name="$PROJECT_DIR$/pkg/internal/server/exts" /> <recent name="$PROJECT_DIR$/pkg/internal/server/exts" />
<recent name="$PROJECT_DIR$/pkg/internal/server/api" /> <recent name="$PROJECT_DIR$/pkg/internal/server/api" />
<recent name="$PROJECT_DIR$/pkg/internal" /> <recent name="$PROJECT_DIR$/pkg/internal" />
<recent name="$PROJECT_DIR$/pkg" /> <recent name="$PROJECT_DIR$/pkg" />
<recent name="$PROJECT_DIR$/pkg/views/users/directory" />
</key> </key>
</component> </component>
<component name="RunAnythingCache"> <component name="RunAnythingCache">
@ -156,6 +152,8 @@
</option> </option>
</component> </component>
<component name="VcsManagerConfiguration"> <component name="VcsManagerConfiguration">
<MESSAGE value=":bug: Fix frontend" />
<MESSAGE value=":card_file_box: Add the status model" />
<MESSAGE value=":bug: Authenticate wrong payload hotfix" /> <MESSAGE value=":bug: Authenticate wrong payload hotfix" />
<MESSAGE value=":sparkles: Can pick up mfa request" /> <MESSAGE value=":sparkles: Can pick up mfa request" />
<MESSAGE value=":sparkles: Status system" /> <MESSAGE value=":sparkles: Status system" />
@ -179,9 +177,7 @@
<MESSAGE value=":sparkles: Admin notify all API" /> <MESSAGE value=":sparkles: Admin notify all API" />
<MESSAGE value=":bug: Fix request body validation" /> <MESSAGE value=":bug: Fix request body validation" />
<MESSAGE value=":bug: Fix API mapping issue" /> <MESSAGE value=":bug: Fix API mapping issue" />
<MESSAGE value=":recycle: Improve notify API" /> <option name="LAST_COMMIT_MESSAGE" value=":bug: Fix API mapping issue" />
<MESSAGE value=":sparkles: Reset password APIs" />
<option name="LAST_COMMIT_MESSAGE" value=":sparkles: Reset password APIs" />
</component> </component>
<component name="VgoProject"> <component name="VgoProject">
<settings-migrated>true</settings-migrated> <settings-migrated>true</settings-migrated>

View File

@ -7,7 +7,6 @@ type MagicTokenType = int8
const ( const (
ConfirmMagicToken = MagicTokenType(iota) ConfirmMagicToken = MagicTokenType(iota)
RegistrationMagicToken RegistrationMagicToken
ResetPasswordMagicToken
) )
type MagicToken struct { type MagicToken struct {

View File

@ -14,20 +14,6 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
) )
func lookupAccount(c *fiber.Ctx) error {
probe := c.Query("probe")
if len(probe) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "you must provide a probe")
}
user, err := services.LookupAccount(probe)
if err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
return c.JSON(user)
}
func getUserinfo(c *fiber.Ctx) error { func getUserinfo(c *fiber.Ctx) error {
if err := exts.EnsureAuthenticated(c); err != nil { if err := exts.EnsureAuthenticated(c); err != nil {
return err return err
@ -137,6 +123,23 @@ func editUserinfo(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK) return c.SendStatus(fiber.StatusOK)
} }
func killTicket(c *fiber.Ctx) error {
if err := exts.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(models.Account)
id, _ := c.ParamsInt("ticketId", 0)
if err := database.C.Delete(&models.AuthTicket{}, &models.AuthTicket{
BaseModel: models.BaseModel{ID: uint(id)},
AccountID: user.ID,
}).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
return c.SendStatus(fiber.StatusOK)
}
func doRegister(c *fiber.Ctx) error { func doRegister(c *fiber.Ctx) error {
var data struct { var data struct {
Name string `json:"name" validate:"required,lowercase,alphanum,min=4,max=16"` Name string `json:"name" validate:"required,lowercase,alphanum,min=4,max=16"`

View File

@ -2,7 +2,6 @@ package api
import ( import (
"fmt" "fmt"
"git.solsynth.dev/hydrogen/passport/pkg/internal/server/exts"
"git.solsynth.dev/hydrogen/passport/pkg/internal/services" "git.solsynth.dev/hydrogen/passport/pkg/internal/services"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@ -41,43 +40,3 @@ func requestFactorToken(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK) return c.SendStatus(fiber.StatusOK)
} }
} }
func requestResetPassword(c *fiber.Ctx) error {
var data struct {
UserID uint `json:"user_id" validate:"required"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
}
user, err := services.GetAccount(data.UserID)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if err = services.CheckAbleToResetPassword(user); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
} else if err = services.RequestResetPassword(user); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return c.SendStatus(fiber.StatusOK)
}
func confirmResetPassword(c *fiber.Ctx) error {
var data struct {
Code string `json:"code" validate:"required"`
NewPassword string `json:"new_password" validate:"required"`
}
if err := exts.BindAndValidate(c, &data); err != nil {
return err
}
if err := services.ConfirmResetPassword(data.Code, data.NewPassword); err != nil {
return fiber.NewError(fiber.StatusBadRequest, err.Error())
}
return c.SendStatus(fiber.StatusOK)
}

View File

@ -19,8 +19,6 @@ func MapAPIs(app *fiber.App) {
notify.Put("/:notificationId/read", markNotificationRead) notify.Put("/:notificationId/read", markNotificationRead)
} }
api.Get("/users/lookup", lookupAccount)
me := api.Group("/users/me").Name("Myself Operations") me := api.Group("/users/me").Name("Myself Operations")
{ {
@ -36,8 +34,6 @@ func MapAPIs(app *fiber.App) {
me.Delete("/tickets/:ticketId", killTicket) me.Delete("/tickets/:ticketId", killTicket)
me.Post("/confirm", doRegisterConfirm) me.Post("/confirm", doRegisterConfirm)
me.Post("/password-reset", requestResetPassword)
me.Patch("/password-reset", confirmResetPassword)
me.Get("/status", getMyselfStatus) me.Get("/status", getMyselfStatus)
me.Post("/status", setStatus) me.Post("/status", setStatus)

View File

@ -38,20 +38,3 @@ func getTickets(c *fiber.Ctx) error {
"data": tickets, "data": tickets,
}) })
} }
func killTicket(c *fiber.Ctx) error {
if err := exts.EnsureAuthenticated(c); err != nil {
return err
}
user := c.Locals("user").(models.Account)
id, _ := c.ParamsInt("ticketId", 0)
if err := database.C.Delete(&models.AuthTicket{}, &models.AuthTicket{
BaseModel: models.BaseModel{ID: uint(id)},
AccountID: user.ID,
}).Error; err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
return c.SendStatus(fiber.StatusOK)
}

View File

@ -101,8 +101,6 @@ func ConfirmAccount(code string) error {
token, err := ValidateMagicToken(code, models.ConfirmMagicToken) token, err := ValidateMagicToken(code, models.ConfirmMagicToken)
if err != nil { if err != nil {
return err return err
} else if token.AccountID == nil {
return fmt.Errorf("magic token didn't assign a valid account")
} }
var user models.Account var user models.Account
@ -136,62 +134,6 @@ func ConfirmAccount(code string) error {
}) })
} }
func CheckAbleToResetPassword(user models.Account) error {
var count int64
if err := database.C.
Where("account_id = ?", user.ID).
Where("expired_at < ?", time.Now()).
Where("type = ?", models.ResetPasswordMagicToken).
Model(&models.MagicToken{}).
Count(&count).Error; err != nil {
return fmt.Errorf("unable to check reset password ability: %v", err)
} else if count > 0 {
return fmt.Errorf("you requested reset password recently")
}
return nil
}
func RequestResetPassword(user models.Account) error {
if tk, err := NewMagicToken(
models.ResetPasswordMagicToken,
&user,
lo.ToPtr(time.Now().Add(24*time.Hour)),
); err != nil {
return err
} else if err := NotifyMagicToken(tk); err != nil {
log.Error().
Err(err).
Str("code", tk.Code).
Uint("user", user.ID).
Msg("Failed to notify password reset magic token...")
}
return nil
}
func ConfirmResetPassword(code, newPassword string) error {
token, err := ValidateMagicToken(code, models.ResetPasswordMagicToken)
if err != nil {
return err
} else if token.AccountID == nil {
return fmt.Errorf("magic token didn't assign a valid account")
}
factor, err := GetPasswordTypeFactor(*token.AccountID)
if err != nil {
factor = models.AuthFactor{
Type: models.PasswordAuthFactor,
Secret: HashPassword(newPassword),
AccountID: *token.AccountID,
}
} else {
factor.Secret = HashPassword(newPassword)
}
return database.C.Save(&factor).Error
}
func DeleteAccount(id uint) error { func DeleteAccount(id uint) error {
tx := database.C.Begin() tx := database.C.Begin()

View File

@ -27,21 +27,6 @@ Once again, thank you for choosing us. We look forward to serving you and hope y
Best regards, Best regards,
%s` %s`
const ResetPasswordTemplate = `Dear %s,
We received a request to reset the password for your account at %s. If you did not request a password reset, please ignore this email.
To confirm your password reset request and create a new password, please use the link below:
%s
This link will expire in 24 hours. If you do not reset your password within this time frame, you will need to submit a new password reset request.
If you have any questions or need further assistance, please do not hesitate to contact our support team.
Best regards,
%s`
func ValidateMagicToken(code string, mode models.MagicTokenType) (models.MagicToken, error) { func ValidateMagicToken(code string, mode models.MagicTokenType) (models.MagicToken, error) {
var tk models.MagicToken var tk models.MagicToken
if err := database.C.Where(models.MagicToken{Code: code, Type: mode}).First(&tk).Error; err != nil { if err := database.C.Where(models.MagicToken{Code: code, Type: mode}).First(&tk).Error; err != nil {
@ -89,7 +74,7 @@ func NotifyMagicToken(token models.MagicToken) error {
var content string var content string
switch token.Type { switch token.Type {
case models.ConfirmMagicToken: case models.ConfirmMagicToken:
link := fmt.Sprintf("https://%s/flow/confirm?code=%s", viper.GetString("domain"), token.Code) link := fmt.Sprintf("https://%s/me/confirm?tk=%s", viper.GetString("domain"), token.Code)
subject = fmt.Sprintf("[%s] Confirm your registration", viper.GetString("name")) subject = fmt.Sprintf("[%s] Confirm your registration", viper.GetString("name"))
content = fmt.Sprintf( content = fmt.Sprintf(
ConfirmRegistrationTemplate, ConfirmRegistrationTemplate,
@ -99,16 +84,6 @@ func NotifyMagicToken(token models.MagicToken) error {
link, link,
viper.GetString("maintainer"), viper.GetString("maintainer"),
) )
case models.ResetPasswordMagicToken:
link := fmt.Sprintf("https://%s/flow/password-reset?code=%s", viper.GetString("domain"), token.Code)
subject = fmt.Sprintf("[%s] Reset your password", viper.GetString("name"))
content = fmt.Sprintf(
ResetPasswordTemplate,
user.Name,
viper.GetString("name"),
link,
viper.GetString("maintainer"),
)
default: default:
return fmt.Errorf("unsupported magic token type to notify") return fmt.Errorf("unsupported magic token type to notify")
} }

View File

@ -51,29 +51,18 @@ const router = createRouter({
meta: { public: true, title: "Sign up" }, meta: { public: true, title: "Sign up" },
}, },
{ {
path: "/authorize", path: "authorize",
name: "oauth.authorize", name: "oauth.authorize",
component: () => import("@/views/auth/authorize.vue"), component: () => import("@/views/auth/authorize.vue"),
}, },
], ],
}, },
{ {
path: "/flow", path: "/users/me/confirm",
children: [
{
path: "confirm",
name: "callback.confirm", name: "callback.confirm",
component: () => import("@/views/flow/confirm.vue"), component: () => import("@/views/confirm.vue"),
meta: { public: true, title: "Confirm registration" }, meta: { public: true, title: "Confirm registration" },
}, },
{
path: "password-reset",
name: "callback.password-reset",
component: () => import("@/views/flow/password-reset.vue"),
meta: { public: true, title: "Reset password" },
},
],
},
], ],
}) })

View File

@ -5,7 +5,7 @@
<div> <div>
<v-avatar color="accent" icon="mdi-check-decagram" size="large" class="card-rounded mb-2" /> <v-avatar color="accent" icon="mdi-check-decagram" size="large" class="card-rounded mb-2" />
<h1 class="text-2xl">Confirm registration</h1> <h1 class="text-2xl">Confirm registration</h1>
<p>Confirm your account to unlock more abilities.</p> <p>Confirm your account to keep your account longer than 48 hours.</p>
</div> </div>
<v-window :touch="false" :model-value="panel" class="pa-2 mx-[-0.5rem]"> <v-window :touch="false" :model-value="panel" class="pa-2 mx-[-0.5rem]">
@ -30,7 +30,7 @@
<h1 class="font-bold text-xl">Confirmed</h1> <h1 class="font-bold text-xl">Confirmed</h1>
<p>You're done! We successfully confirmed your account.</p> <p>You're done! We successfully confirmed your account.</p>
<p class="mt-3">Now you can continue to use Solarpass, we will redirect you to dashboard soon.</p> <p class="mt-3">Now you can continue use Solarpass, we will redirect to dashboard you soon.</p>
</div> </div>
</v-window-item> </v-window-item>
</v-window> </v-window>
@ -59,7 +59,7 @@ const loading = ref(false)
const panel = ref("confirm") const panel = ref("confirm")
async function confirm() { async function confirm() {
if (!route.query["code"]) { if (!route.query["tk"]) {
error.value = "code was not exists" error.value = "code was not exists"
return return
} }
@ -68,7 +68,7 @@ async function confirm() {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
code: route.query["code"], code: route.query["tk"],
}), }),
}) })
if (res.status !== 200) { if (res.status !== 200) {
@ -77,7 +77,7 @@ async function confirm() {
loading.value = true loading.value = true
panel.value = "callback" panel.value = "callback"
await readProfiles() await readProfiles()
await router.push({ name: "dashboard" }) router.push({ name: "dashboard" })
} }
loading.value = false loading.value = false
} }

View File

@ -1,122 +0,0 @@
<template>
<v-container class="h-screen flex flex-col gap-3 items-center justify-center">
<v-card class="w-full max-w-[720px] overflow-auto" :loading="loading">
<v-card-text class="card-grid pa-9">
<div>
<v-avatar color="accent" icon="mdi-lock-reset" size="large" class="card-rounded mb-2" />
<h1 class="text-2xl">Reset password</h1>
<p>Reset password to get back access of your account.</p>
</div>
<v-window :touch="false" :model-value="panel" class="pa-2 mx-[-0.5rem]">
<v-window-item value="confirm">
<div class="flex items-center">
<v-form class="flex-grow-1" @submit.prevent="confirm">
<v-text-field
label="New Password"
type="password"
autocomplete="new-password"
variant="solo"
density="comfortable"
:disabled="loading"
v-model="newPassword"
/>
<v-expand-transition>
<v-alert v-show="error" variant="tonal" type="error" class="text-xs mb-3">
Something went wrong... {{ error }}
</v-alert>
</v-expand-transition>
<div class="flex justify-end">
<v-btn
type="submit"
variant="text"
color="primary"
class="justify-self-end"
append-icon="mdi-arrow-right"
:disabled="loading"
>
Next
</v-btn>
</div>
</v-form>
</div>
</v-window-item>
<v-window-item value="callback">
<div>
<v-icon icon="mdi-fire" size="32" color="grey-darken-3" class="mb-3" />
<h1 class="font-bold text-xl">Applied</h1>
<p>The password of your account has updated successfully.</p>
<p class="mt-3">Now you can continue to use Solarpass, we will redirect you to sign-in soon.</p>
</div>
</v-window-item>
</v-window>
</v-card-text>
</v-card>
<copyright />
</v-container>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { useRoute, useRouter } from "vue-router"
import { request } from "@/scripts/request"
import Copyright from "@/components/Copyright.vue"
const route = useRoute()
const router = useRouter()
const error = ref<string | null>(null)
const loading = ref(false)
const panel = ref("confirm")
const newPassword = ref("")
async function confirm() {
if (!route.query["code"]) {
error.value = "code was not exists"
return
}
const res = await request("/api/users/me/password-reset", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code: route.query["code"],
new_password: newPassword.value,
}),
})
if (res.status !== 200) {
error.value = await res.text()
} else {
loading.value = true
panel.value = "callback"
await router.push({ name: "auth.sign-in" })
}
loading.value = false
}
</script>
<style scoped>
.card-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 768px) {
.card-grid {
grid-template-columns: 1fr;
}
}
.card-rounded {
border-radius: 8px;
}
</style>