✨ DysonNetwork.Pass service frontend
This commit is contained in:
		@@ -15,9 +15,12 @@ public class AuthController(
 | 
			
		||||
    AccountService accounts,
 | 
			
		||||
    AuthService auth,
 | 
			
		||||
    GeoIpService geo,
 | 
			
		||||
    ActionLogService als
 | 
			
		||||
    ActionLogService als,
 | 
			
		||||
    IConfiguration configuration
 | 
			
		||||
) : ControllerBase
 | 
			
		||||
{
 | 
			
		||||
    private readonly string CookieDomain = configuration["AuthToken:CookieDomain"]!;
 | 
			
		||||
    
 | 
			
		||||
    public class ChallengeRequest
 | 
			
		||||
    {
 | 
			
		||||
        [Required] public ChallengePlatform Platform { get; set; }
 | 
			
		||||
@@ -80,8 +83,8 @@ public class AuthController(
 | 
			
		||||
            .ThenInclude(e => e.Profile)
 | 
			
		||||
            .FirstOrDefaultAsync(e => e.Id == id);
 | 
			
		||||
 | 
			
		||||
        return challenge is null 
 | 
			
		||||
            ? NotFound("Auth challenge was not found.") 
 | 
			
		||||
        return challenge is null
 | 
			
		||||
            ? NotFound("Auth challenge was not found.")
 | 
			
		||||
            : challenge;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -249,11 +252,19 @@ public class AuthController(
 | 
			
		||||
                await db.SaveChangesAsync();
 | 
			
		||||
 | 
			
		||||
                var tk = auth.CreateToken(session);
 | 
			
		||||
                Response.Cookies.Append(AuthConstants.CookieTokenName, tk, new CookieOptions
 | 
			
		||||
                {
 | 
			
		||||
                    HttpOnly = true,
 | 
			
		||||
                    Secure = true,
 | 
			
		||||
                    SameSite = SameSiteMode.Lax,
 | 
			
		||||
                    Domain = CookieDomain,
 | 
			
		||||
                    Expires = DateTime.UtcNow.AddDays(30)
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                return Ok(new TokenExchangeResponse { Token = tk });
 | 
			
		||||
            case "refresh_token":
 | 
			
		||||
            // Since we no longer need the refresh token
 | 
			
		||||
            // This case is blank for now, thinking to mock it if the OIDC standard requires it
 | 
			
		||||
            default:
 | 
			
		||||
                // Since we no longer need the refresh token
 | 
			
		||||
                // This case is blank for now, thinking to mock it if the OIDC standard requires it
 | 
			
		||||
                return BadRequest("Unsupported grant type.");
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
@@ -264,4 +275,17 @@ public class AuthController(
 | 
			
		||||
        var result = await auth.ValidateCaptcha(token);
 | 
			
		||||
        return result ? Ok() : BadRequest();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    [HttpPost("logout")]
 | 
			
		||||
    public IActionResult Logout()
 | 
			
		||||
    {
 | 
			
		||||
        Response.Cookies.Delete(AuthConstants.CookieTokenName, new CookieOptions
 | 
			
		||||
        {
 | 
			
		||||
            Domain = CookieDomain,
 | 
			
		||||
            HttpOnly = true,
 | 
			
		||||
            Secure = true,
 | 
			
		||||
            SameSite = SameSiteMode.Lax
 | 
			
		||||
        });
 | 
			
		||||
        return Ok();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										18
									
								
								DysonNetwork.Pass/Auth/CaptchaController.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								DysonNetwork.Pass/Auth/CaptchaController.cs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
			
		||||
using Microsoft.AspNetCore.Mvc;
 | 
			
		||||
 | 
			
		||||
namespace DysonNetwork.Pass.Auth;
 | 
			
		||||
 | 
			
		||||
[ApiController]
 | 
			
		||||
[Route("/api/captcha")]
 | 
			
		||||
public class CaptchaController(IConfiguration configuration) : ControllerBase
 | 
			
		||||
{
 | 
			
		||||
    [HttpGet]
 | 
			
		||||
    public IActionResult GetConfiguration()
 | 
			
		||||
    {
 | 
			
		||||
        return Ok(new
 | 
			
		||||
        {
 | 
			
		||||
            provider = configuration["Captcha:Provider"],
 | 
			
		||||
            apiKey = configuration["Captcha:ApiKey"],
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -6,8 +6,11 @@
 | 
			
		||||
      "dependencies": {
 | 
			
		||||
        "@fingerprintjs/fingerprintjs": "^4.6.2",
 | 
			
		||||
        "@fontsource-variable/nunito": "^5.2.6",
 | 
			
		||||
        "@hcaptcha/vue3-hcaptcha": "^1.3.0",
 | 
			
		||||
        "@tailwindcss/vite": "^4.1.11",
 | 
			
		||||
        "@vueuse/core": "^13.5.0",
 | 
			
		||||
        "aspnet-prerendering": "^3.0.1",
 | 
			
		||||
        "cfturnstile-vue3": "^2.0.0",
 | 
			
		||||
        "pinia": "^3.0.3",
 | 
			
		||||
        "tailwindcss": "^4.1.11",
 | 
			
		||||
        "vue": "^3.5.17",
 | 
			
		||||
@@ -136,6 +139,8 @@
 | 
			
		||||
 | 
			
		||||
    "@fontsource-variable/nunito": ["@fontsource-variable/nunito@5.2.6", "", {}, "sha512-dGYTQ0Hl94jjfMraYefrURHGH8fk/vL/1zYAZGofiPJVs6C0OkM8T87Te5Gwrbe6HG/XEMm5lib8AqasTN3ucw=="],
 | 
			
		||||
 | 
			
		||||
    "@hcaptcha/vue3-hcaptcha": ["@hcaptcha/vue3-hcaptcha@1.3.0", "", { "dependencies": { "vue": "^3.2.19" } }, "sha512-IEonS6JiYdU7uy6aeib8cYtMO4nj8utwStbA9bWHyYbOvOvhpkV+AW8vfSKh6SntYxqle/TRwhv+kU9p92CfsA=="],
 | 
			
		||||
 | 
			
		||||
    "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
 | 
			
		||||
 | 
			
		||||
    "@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="],
 | 
			
		||||
@@ -270,6 +275,8 @@
 | 
			
		||||
 | 
			
		||||
    "@types/node": ["@types/node@22.16.4", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-PYRhNtZdm2wH/NT2k/oAJ6/f2VD2N2Dag0lGlx2vWgMSJXGNmlce5MiTQzoWAiIJtso30mjnfQCOKVH+kAQC/g=="],
 | 
			
		||||
 | 
			
		||||
    "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
 | 
			
		||||
 | 
			
		||||
    "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.37.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.37.0", "@typescript-eslint/type-utils": "8.37.0", "@typescript-eslint/utils": "8.37.0", "@typescript-eslint/visitor-keys": "8.37.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.37.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-jsuVWeIkb6ggzB+wPCsR4e6loj+rM72ohW6IBn2C+5NCvfUVY8s33iFPySSVXqtm5Hu29Ne/9bnA0JmyLmgenA=="],
 | 
			
		||||
 | 
			
		||||
    "@typescript-eslint/parser": ["@typescript-eslint/parser@8.37.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.37.0", "@typescript-eslint/types": "8.37.0", "@typescript-eslint/typescript-estree": "8.37.0", "@typescript-eslint/visitor-keys": "8.37.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-kVIaQE9vrN9RLCQMQ3iyRlVJpTiDUY6woHGb30JDkfJErqrQEmtdWH3gV0PBAfGZgQXoqzXOO0T3K6ioApbbAA=="],
 | 
			
		||||
@@ -344,6 +351,12 @@
 | 
			
		||||
 | 
			
		||||
    "@vue/tsconfig": ["@vue/tsconfig@0.7.0", "", { "peerDependencies": { "typescript": "5.x", "vue": "^3.4.0" }, "optionalPeers": ["typescript", "vue"] }, "sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg=="],
 | 
			
		||||
 | 
			
		||||
    "@vueuse/core": ["@vueuse/core@13.5.0", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "13.5.0", "@vueuse/shared": "13.5.0" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-wV7z0eUpifKmvmN78UBZX8T7lMW53Nrk6JP5+6hbzrB9+cJ3jr//hUlhl9TZO/03bUkMK6gGkQpqOPWoabr72g=="],
 | 
			
		||||
 | 
			
		||||
    "@vueuse/metadata": ["@vueuse/metadata@13.5.0", "", {}, "sha512-euhItU3b0SqXxSy8u1XHxUCdQ8M++bsRs+TYhOLDU/OykS7KvJnyIFfep0XM5WjIFry9uAPlVSjmVHiqeshmkw=="],
 | 
			
		||||
 | 
			
		||||
    "@vueuse/shared": ["@vueuse/shared@13.5.0", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-K7GrQIxJ/ANtucxIXbQlUHdB0TPA8c+q5i+zbrjxuhJCnJ9GtBg75sBSnvmLSxHKPg2Yo8w62PWksl9kwH0Q8g=="],
 | 
			
		||||
 | 
			
		||||
    "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
 | 
			
		||||
 | 
			
		||||
    "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
 | 
			
		||||
@@ -380,6 +393,8 @@
 | 
			
		||||
 | 
			
		||||
    "caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="],
 | 
			
		||||
 | 
			
		||||
    "cfturnstile-vue3": ["cfturnstile-vue3@2.0.0", "", { "dependencies": { "vue": "^3.2.38" } }, "sha512-wamRC8ZoUAjvfOVoPAbJM14qqxc0gfjqfV6ESZh4rMs7G0yp+R4dpHNjxa7YAjdFTutaviMEZYCuK9tM4ZaGJQ=="],
 | 
			
		||||
 | 
			
		||||
    "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
 | 
			
		||||
 | 
			
		||||
    "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
 | 
			
		||||
 
 | 
			
		||||
@@ -17,8 +17,11 @@
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@fingerprintjs/fingerprintjs": "^4.6.2",
 | 
			
		||||
    "@fontsource-variable/nunito": "^5.2.6",
 | 
			
		||||
    "@hcaptcha/vue3-hcaptcha": "^1.3.0",
 | 
			
		||||
    "@tailwindcss/vite": "^4.1.11",
 | 
			
		||||
    "@vueuse/core": "^13.5.0",
 | 
			
		||||
    "aspnet-prerendering": "^3.0.1",
 | 
			
		||||
    "cfturnstile-vue3": "^2.0.0",
 | 
			
		||||
    "pinia": "^3.0.3",
 | 
			
		||||
    "tailwindcss": "^4.1.11",
 | 
			
		||||
    "vue": "^3.5.17",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										65
									
								
								DysonNetwork.Pass/Client/src/components/Captcha.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								DysonNetwork.Pass/Client/src/components/Captcha.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,65 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="flex justify-center">
 | 
			
		||||
    <div v-if="provider === 'cloudflare'">
 | 
			
		||||
      <turnstile v-if="!!apiKey" :sitekey="apiKey" @callback="handleSuccess" />
 | 
			
		||||
      <div v-else class="mx-auto">
 | 
			
		||||
        <n-spin />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div v-else-if="provider === 'recaptcha'">
 | 
			
		||||
      <hcaptcha v-if="!!apiKey" :sitekey="apiKey" @verify="(tk: string) => handleSuccess(tk)" />
 | 
			
		||||
      <div v-else class="mx-auto">
 | 
			
		||||
        <n-spin />
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div v-else-if="provider === 'hcaptcha'" class="h-captcha" :data-sitekey="apiKey"></div>
 | 
			
		||||
    <div v-else class="flex flex-col items-center justify-center gap-1">
 | 
			
		||||
      <n-icon size="32">
 | 
			
		||||
        <error-outline-round />
 | 
			
		||||
      </n-icon>
 | 
			
		||||
      <span>Captcha provider not configured correctly.</span>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { defineProps, defineEmits, ref, onMounted } from 'vue'
 | 
			
		||||
import { NIcon, NSpin } from 'naive-ui'
 | 
			
		||||
import { ErrorOutlineRound } from '@vicons/material'
 | 
			
		||||
 | 
			
		||||
import Turnstile from 'cfturnstile-vue3'
 | 
			
		||||
import Hcaptcha from '@hcaptcha/vue3-hcaptcha'
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  provider: {
 | 
			
		||||
    type: String,
 | 
			
		||||
    required: false,
 | 
			
		||||
  },
 | 
			
		||||
  apiKey: {
 | 
			
		||||
    type: String,
 | 
			
		||||
    required: false,
 | 
			
		||||
  },
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const provider = ref(props.provider)
 | 
			
		||||
const apiKey = ref(props.apiKey)
 | 
			
		||||
 | 
			
		||||
const emit = defineEmits(['verified'])
 | 
			
		||||
 | 
			
		||||
function handleSuccess(token: string) {
 | 
			
		||||
  emit('verified', token)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// This function will be used to fetch configuration if needed,
 | 
			
		||||
// Like the backend didn't embed the configuration properly.
 | 
			
		||||
async function fetchConfiguration() {
 | 
			
		||||
  const resp = await fetch('/api/captcha')
 | 
			
		||||
  const data = await resp.json()
 | 
			
		||||
  provider.value = data.provider
 | 
			
		||||
  apiKey.value = data.api_key
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
  if (!provider.value || !apiKey.value) fetchConfiguration()
 | 
			
		||||
})
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,25 +1,115 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <n-layout>
 | 
			
		||||
    <n-layout-header class="border-b-1">Solar Network ID</n-layout-header>
 | 
			
		||||
    <n-layout-header class="border-b-1 flex justify-between items-center">
 | 
			
		||||
      <router-link to="/" class="text-lg font-bold">Solar Network ID</router-link>
 | 
			
		||||
      <div v-if="!hideUserMenu">
 | 
			
		||||
        <n-dropdown
 | 
			
		||||
          v-if="!userStore.isAuthenticated"
 | 
			
		||||
          :options="guestOptions"
 | 
			
		||||
          @select="handleGuestMenuSelect"
 | 
			
		||||
        >
 | 
			
		||||
          <n-button>Account</n-button>
 | 
			
		||||
        </n-dropdown>
 | 
			
		||||
        <n-dropdown v-else :options="userOptions" @select="handleUserMenuSelect" type="primary">
 | 
			
		||||
          <n-button>{{ userStore.user.nick }}</n-button>
 | 
			
		||||
        </n-dropdown>
 | 
			
		||||
      </div>
 | 
			
		||||
    </n-layout-header>
 | 
			
		||||
    <n-layout-content embedded content-style="padding: 24px;">
 | 
			
		||||
      <slot />
 | 
			
		||||
      <router-view />
 | 
			
		||||
    </n-layout-content>
 | 
			
		||||
  </n-layout>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { NLayout, NLayoutHeader, NLayoutContent } from 'naive-ui'
 | 
			
		||||
import { computed, h } from 'vue'
 | 
			
		||||
import { NLayout, NLayoutHeader, NLayoutContent, NButton, NDropdown, NIcon } from 'naive-ui'
 | 
			
		||||
import {
 | 
			
		||||
  LogInOutlined,
 | 
			
		||||
  PersonAddAlt1Outlined,
 | 
			
		||||
  LogOutOutlined,
 | 
			
		||||
  PersonOutlineRound,
 | 
			
		||||
} from '@vicons/material'
 | 
			
		||||
import { useUserStore } from '@/stores/user'
 | 
			
		||||
import { useRoute, useRouter } from 'vue-router'
 | 
			
		||||
 | 
			
		||||
const userStore = useUserStore()
 | 
			
		||||
const route = useRoute()
 | 
			
		||||
const router = useRouter()
 | 
			
		||||
 | 
			
		||||
// Initialize user state on component mount
 | 
			
		||||
userStore.initialize()
 | 
			
		||||
 | 
			
		||||
const hideUserMenu = computed(() => {
 | 
			
		||||
  return ['captcha', 'spells', 'login', 'create-account'].includes(route.name as string)
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const guestOptions = [
 | 
			
		||||
  {
 | 
			
		||||
    label: 'Login',
 | 
			
		||||
    key: 'login',
 | 
			
		||||
    icon: () =>
 | 
			
		||||
      h(NIcon, null, {
 | 
			
		||||
        default: () => h(LogInOutlined),
 | 
			
		||||
      }),
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    label: 'Create Account',
 | 
			
		||||
    key: 'create-account',
 | 
			
		||||
    icon: () =>
 | 
			
		||||
      h(NIcon, null, {
 | 
			
		||||
        default: () => h(PersonAddAlt1Outlined),
 | 
			
		||||
      }),
 | 
			
		||||
  },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
const userOptions = computed(() => [
 | 
			
		||||
  {
 | 
			
		||||
    label: 'Profile',
 | 
			
		||||
    key: 'profile',
 | 
			
		||||
    icon: () =>
 | 
			
		||||
      h(NIcon, null, {
 | 
			
		||||
        default: () => h(PersonOutlineRound),
 | 
			
		||||
      }),
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    label: 'Logout',
 | 
			
		||||
    key: 'logout',
 | 
			
		||||
    icon: () =>
 | 
			
		||||
      h(NIcon, null, {
 | 
			
		||||
        default: () => h(LogOutOutlined),
 | 
			
		||||
      }),
 | 
			
		||||
  },
 | 
			
		||||
])
 | 
			
		||||
 | 
			
		||||
function handleGuestMenuSelect(key: string) {
 | 
			
		||||
  if (key === 'login') {
 | 
			
		||||
    router.push('/login')
 | 
			
		||||
  } else if (key === 'create-account') {
 | 
			
		||||
    router.push('/create-account')
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function handleUserMenuSelect(key: string) {
 | 
			
		||||
  if (key === 'logout') {
 | 
			
		||||
    userStore.logout()
 | 
			
		||||
    router.push('/login')
 | 
			
		||||
  } else if (key === 'profile') {
 | 
			
		||||
    router.push('/accounts/me') // Assuming you have a profile page
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
.n-layout-header,
 | 
			
		||||
.n-layout-footer {
 | 
			
		||||
.n-layout-header {
 | 
			
		||||
  padding: 8px 24px;
 | 
			
		||||
  border-color: var(--n-border-color);
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  height: 57px; /* Fixed height */
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.n-layout-content {
 | 
			
		||||
  height: calc(100vh - 40px);
 | 
			
		||||
  height: calc(100vh - 57px); /* Adjust based on header height */
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 
 | 
			
		||||
@@ -2,14 +2,37 @@
 | 
			
		||||
import LayoutDefault from './layouts/default.vue'
 | 
			
		||||
 | 
			
		||||
import { RouterView } from 'vue-router'
 | 
			
		||||
import { NGlobalStyle, NConfigProvider } from 'naive-ui'
 | 
			
		||||
import { NGlobalStyle, NConfigProvider, NMessageProvider, lightTheme, darkTheme } from 'naive-ui'
 | 
			
		||||
import { usePreferredDark } from '@vueuse/core'
 | 
			
		||||
import { useUserStore } from './stores/user'
 | 
			
		||||
import { onMounted } from 'vue'
 | 
			
		||||
 | 
			
		||||
const themeOverrides = {
 | 
			
		||||
  common: {
 | 
			
		||||
    fontFamily: 'Nunito Variable, v-sans, ui-system, -apple-system, sans-serif',
 | 
			
		||||
    primaryColor: '#7D80BAFF',
 | 
			
		||||
    primaryColorHover: '#9294C5FF',
 | 
			
		||||
    primaryColorPressed: '#575B9DFF',
 | 
			
		||||
    primaryColorSuppl: '#6B6FC1FF',
 | 
			
		||||
  },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const isDark = usePreferredDark()
 | 
			
		||||
 | 
			
		||||
const userStore = useUserStore()
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
  userStore.fetchUser()
 | 
			
		||||
})
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <n-config-provider :theme-overrides="{ common: { fontFamily: 'Nunito Variable' } }">
 | 
			
		||||
  <n-config-provider :theme-overrides="themeOverrides" :theme="isDark ? darkTheme : lightTheme">
 | 
			
		||||
    <n-global-style />
 | 
			
		||||
    <layout-default>
 | 
			
		||||
      <router-view />
 | 
			
		||||
    </layout-default>
 | 
			
		||||
    <n-message-provider placement="bottom">
 | 
			
		||||
      <layout-default>
 | 
			
		||||
        <router-view />
 | 
			
		||||
      </layout-default>
 | 
			
		||||
    </n-message-provider>
 | 
			
		||||
  </n-config-provider>
 | 
			
		||||
</template>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
import { createRouter, createWebHistory } from 'vue-router'
 | 
			
		||||
import { useUserStore } from '@/stores/user'
 | 
			
		||||
 | 
			
		||||
const router = createRouter({
 | 
			
		||||
  history: createWebHistory(import.meta.env.BASE_URL),
 | 
			
		||||
@@ -6,24 +7,50 @@ const router = createRouter({
 | 
			
		||||
    {
 | 
			
		||||
      path: '/',
 | 
			
		||||
      name: 'index',
 | 
			
		||||
      component: () => import('../views/index.vue'),
 | 
			
		||||
      component: () => import('../views/index.vue')
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      path: '/captcha',
 | 
			
		||||
      name: 'captcha',
 | 
			
		||||
      component: () => import('../views/captcha.vue'),
 | 
			
		||||
      component: () => import('../views/captcha.vue')
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      path: '/spells/:word',
 | 
			
		||||
      name: 'spells',
 | 
			
		||||
      component: () => import('../views/spells.vue'),
 | 
			
		||||
      component: () => import('../views/spells.vue')
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      path: '/login',
 | 
			
		||||
      name: 'login',
 | 
			
		||||
      component: () => import('../views/login.vue'),
 | 
			
		||||
      component: () => import('../views/login.vue')
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
    {
 | 
			
		||||
      path: '/create-account',
 | 
			
		||||
      name: 'create-account',
 | 
			
		||||
      component: () => import('../views/create-account.vue')
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      path: '/accounts/me',
 | 
			
		||||
      name: 'me',
 | 
			
		||||
      component: () => import('../views/accounts/me.vue'),
 | 
			
		||||
      meta: { requiresAuth: true }
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
router.beforeEach(async (to, from, next) => {
 | 
			
		||||
  const userStore = useUserStore()
 | 
			
		||||
 | 
			
		||||
  // Initialize user state if not already initialized
 | 
			
		||||
  if (!userStore.user && localStorage.getItem('authToken')) {
 | 
			
		||||
    await userStore.initialize()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (to.matched.some((record) => record.meta.requiresAuth) && !userStore.isAuthenticated) {
 | 
			
		||||
    next({ name: 'login', query: { redirect: to.fullPath } })
 | 
			
		||||
  } else {
 | 
			
		||||
    next()
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
export default router
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										3
									
								
								DysonNetwork.Pass/Client/src/stores/services.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								DysonNetwork.Pass/Client/src/stores/services.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
import { defineStore } from 'pinia'
 | 
			
		||||
 | 
			
		||||
export const useServicesStore = defineStore('services', () => {})
 | 
			
		||||
							
								
								
									
										66
									
								
								DysonNetwork.Pass/Client/src/stores/user.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										66
									
								
								DysonNetwork.Pass/Client/src/stores/user.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,66 @@
 | 
			
		||||
import { defineStore } from 'pinia'
 | 
			
		||||
import { ref, computed } from 'vue'
 | 
			
		||||
 | 
			
		||||
export const useUserStore = defineStore('user', () => {
 | 
			
		||||
  // State
 | 
			
		||||
  const user = ref<any>(null)
 | 
			
		||||
  const isLoading = ref(false)
 | 
			
		||||
  const error = ref<string | null>(null)
 | 
			
		||||
 | 
			
		||||
  // Getters
 | 
			
		||||
  const isAuthenticated = computed(() => !!user.value)
 | 
			
		||||
 | 
			
		||||
  // Actions
 | 
			
		||||
  async function fetchUser() {
 | 
			
		||||
    const token = localStorage.getItem('authToken')
 | 
			
		||||
    if (!token) {
 | 
			
		||||
      return // No token, no need to fetch
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    isLoading.value = true
 | 
			
		||||
    error.value = null
 | 
			
		||||
    try {
 | 
			
		||||
      const response = await fetch('/api/accounts/me', {
 | 
			
		||||
        headers: {
 | 
			
		||||
          'Authorization': `Bearer ${token}`
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        // If the token is invalid, clear it and the user state
 | 
			
		||||
        if (response.status === 401) {
 | 
			
		||||
          logout()
 | 
			
		||||
        }
 | 
			
		||||
        throw new Error('Failed to fetch user information.')
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      user.value = await response.json()
 | 
			
		||||
    } catch (e: any) {
 | 
			
		||||
      error.value = e.message
 | 
			
		||||
      user.value = null // Clear user data on error
 | 
			
		||||
    } finally {
 | 
			
		||||
      isLoading.value = false
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  function logout() {
 | 
			
		||||
    user.value = null
 | 
			
		||||
    localStorage.removeItem('authToken')
 | 
			
		||||
    // Optionally, redirect to login page
 | 
			
		||||
    // router.push('/login')
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async function initialize() {
 | 
			
		||||
    await fetchUser()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    user,
 | 
			
		||||
    isLoading,
 | 
			
		||||
    error,
 | 
			
		||||
    isAuthenticated,
 | 
			
		||||
    fetchUser,
 | 
			
		||||
    logout,
 | 
			
		||||
    initialize
 | 
			
		||||
  }
 | 
			
		||||
})
 | 
			
		||||
							
								
								
									
										55
									
								
								DysonNetwork.Pass/Client/src/views/accounts/me.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								DysonNetwork.Pass/Client/src/views/accounts/me.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,55 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="max-w-3xl mx-auto p-8">
 | 
			
		||||
    <div class="flex items-center gap-6 mb-8">
 | 
			
		||||
      <n-avatar round :size="100" :alt="userStore.user.name">
 | 
			
		||||
        <n-icon size="48">
 | 
			
		||||
          <person-round />
 | 
			
		||||
        </n-icon>
 | 
			
		||||
      </n-avatar>
 | 
			
		||||
      <div>
 | 
			
		||||
        <n-text strong class="text-2xl">
 | 
			
		||||
          {{ userStore.user.nick || userStore.user.name }}
 | 
			
		||||
        </n-text>
 | 
			
		||||
        <n-text depth="3" class="block">@{{ userStore.user.name }}</n-text>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="mb-8">
 | 
			
		||||
      <div class="flex justify-between mb-2">
 | 
			
		||||
        <n-text>Level {{ userStore.user.profile.level }}</n-text>
 | 
			
		||||
        <n-text>{{ userStore.user.profile.experience }} XP</n-text>
 | 
			
		||||
      </div>
 | 
			
		||||
      <n-progress
 | 
			
		||||
        type="line"
 | 
			
		||||
        :percentage="userStore.user.profile.leveling_progress"
 | 
			
		||||
        :height="8"
 | 
			
		||||
        status="success"
 | 
			
		||||
        :show-indicator="false"
 | 
			
		||||
      />
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div v-if="userStore.user.profile.bio" class="mt-8">
 | 
			
		||||
      <n-h3>About</n-h3>
 | 
			
		||||
      <n-p>{{ userStore.user.profile.bio }}</n-p>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="mt-8">
 | 
			
		||||
      <n-button type="primary" icon-placement="right" tag="a" href="https://solian.app/#/account">
 | 
			
		||||
        Open in the Solian
 | 
			
		||||
        <template #icon>
 | 
			
		||||
          <n-icon>
 | 
			
		||||
            <launch-outlined />
 | 
			
		||||
          </n-icon>
 | 
			
		||||
        </template>
 | 
			
		||||
      </n-button>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { NAvatar, NText, NProgress, NH3, NP, NButton, NIcon } from 'naive-ui'
 | 
			
		||||
import { PersonRound, LaunchOutlined } from '@vicons/material'
 | 
			
		||||
import { useUserStore } from '@/stores/user'
 | 
			
		||||
 | 
			
		||||
const userStore = useUserStore()
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,38 +1,34 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="h-full flex items-center justify-center">
 | 
			
		||||
    <n-card class="max-w-lg text-center" title="Captcha">
 | 
			
		||||
      <div class="flex justify-center mb-4 mt-2">
 | 
			
		||||
        <div v-if="provider === 'cloudflare'" class="cf-turnstile" :data-sitekey="apiKey" data-callback="onTurnstileSuccess"></div>
 | 
			
		||||
        <div v-else-if="provider === 'recaptcha'" class="g-recaptcha" :data-sitekey="apiKey" data-callback="onRecaptchaSuccess"></div>
 | 
			
		||||
        <div v-else-if="provider === 'hcaptcha'" class="h-captcha" :data-sitekey="apiKey" data-callback="onHcaptchaSuccess"></div>
 | 
			
		||||
        <div v-else class="alert alert-warning">
 | 
			
		||||
          <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
 | 
			
		||||
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
 | 
			
		||||
          </svg>
 | 
			
		||||
          <span>Captcha provider not configured correctly.</span>
 | 
			
		||||
        </div>
 | 
			
		||||
    <n-card class="max-w-lg text-center" title="Captcha Verification">
 | 
			
		||||
      <div class="mb-4 mt-2">
 | 
			
		||||
        <Captcha :provider="provider" :api-key="apiKey" @verified="onCaptchaVerified" />
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div class="text-sm">
 | 
			
		||||
        <div class="font-semibold mb-1">Solar Network Anti-Robot</div>
 | 
			
		||||
        <div class="text-base-content/70">
 | 
			
		||||
          Powered by
 | 
			
		||||
          <template v-if="provider === 'cloudflare'">
 | 
			
		||||
            <a href="https://www.cloudflare.com/turnstile/" class="link link-hover">
 | 
			
		||||
            <a href="https://www.cloudflare.com/turnstile/" class="link link-hover" target="_blank" rel="noopener noreferrer">
 | 
			
		||||
              Cloudflare Turnstile
 | 
			
		||||
            </a>
 | 
			
		||||
          </template>
 | 
			
		||||
          <template v-else-if="provider === 'recaptcha'">
 | 
			
		||||
            <a href="https://www.google.com/recaptcha/" class="link link-hover">
 | 
			
		||||
            <a href="https://www.google.com/recaptcha/" class="link link-hover" target="_blank" rel="noopener noreferrer">
 | 
			
		||||
              Google reCaptcha
 | 
			
		||||
            </a>
 | 
			
		||||
          </template>
 | 
			
		||||
           <template v-else-if="provider === 'hcaptcha'">
 | 
			
		||||
            <a href="https://www.hcaptcha.com/" class="link link-hover" target="_blank" rel="noopener noreferrer">
 | 
			
		||||
              hCaptcha
 | 
			
		||||
            </a>
 | 
			
		||||
          </template>
 | 
			
		||||
          <template v-else>
 | 
			
		||||
            <span>Nothing</span>
 | 
			
		||||
          </template>
 | 
			
		||||
          <br/>
 | 
			
		||||
          Hosted by
 | 
			
		||||
          <a href="https://github.com/Solsynth/DysonNetwork" class="link link-hover">
 | 
			
		||||
          <a href="https://github.com/Solsynth/DysonNetwork" class="link link-hover" target="_blank" rel="noopener noreferrer">
 | 
			
		||||
            DysonNetwork.Sphere
 | 
			
		||||
          </a>
 | 
			
		||||
        </div>
 | 
			
		||||
@@ -42,43 +38,18 @@
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { onMounted } from 'vue';
 | 
			
		||||
import { ref } from 'vue';
 | 
			
		||||
import { useRoute } from 'vue-router';
 | 
			
		||||
import { NCard } from 'naive-ui';
 | 
			
		||||
import Captcha from '@/components/Captcha.vue';
 | 
			
		||||
 | 
			
		||||
const route = useRoute();
 | 
			
		||||
 | 
			
		||||
// Get provider and API key from app data
 | 
			
		||||
// @ts-ignore
 | 
			
		||||
const { Provider: provider, ApiKey: apiKey } = window.__APP_DATA__ || {};
 | 
			
		||||
const provider = ref((window as any).__APP_DATA__?.Provider || '');
 | 
			
		||||
const apiKey = ref((window as any).__APP_DATA__?.ApiKey || '');
 | 
			
		||||
 | 
			
		||||
// Load the appropriate CAPTCHA script based on provider
 | 
			
		||||
const loadCaptchaScript = () => {
 | 
			
		||||
  if (!provider) return;
 | 
			
		||||
 | 
			
		||||
  const script = document.createElement('script');
 | 
			
		||||
  script.async = true;
 | 
			
		||||
  script.defer = true;
 | 
			
		||||
 | 
			
		||||
  switch (provider.toLowerCase()) {
 | 
			
		||||
    case 'recaptcha':
 | 
			
		||||
      script.src = 'https://www.recaptcha.net/recaptcha/api.js';
 | 
			
		||||
      break;
 | 
			
		||||
    case 'cloudflare':
 | 
			
		||||
      script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
 | 
			
		||||
      break;
 | 
			
		||||
    case 'hcaptcha':
 | 
			
		||||
      script.src = 'https://js.hcaptcha.com/1/api.js';
 | 
			
		||||
      break;
 | 
			
		||||
    default:
 | 
			
		||||
      return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  document.head.appendChild(script);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Handle successful CAPTCHA verification
 | 
			
		||||
(window as any).onTurnstileSuccess = (token: string) => {
 | 
			
		||||
const onCaptchaVerified = (token: string) => {
 | 
			
		||||
  if (window.parent !== window) {
 | 
			
		||||
    window.parent.postMessage(`captcha_tk=${token}`, '*');
 | 
			
		||||
  }
 | 
			
		||||
@@ -88,31 +59,4 @@ const loadCaptchaScript = () => {
 | 
			
		||||
    window.location.href = `${redirectUri}?captcha_tk=${encodeURIComponent(token)}`;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
(window as any).onRecaptchaSuccess = (token: string) => {
 | 
			
		||||
  if (window.parent !== window) {
 | 
			
		||||
    window.parent.postMessage(`captcha_tk=${token}`, '*');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const redirectUri = route.query.redirect_uri as string;
 | 
			
		||||
  if (redirectUri) {
 | 
			
		||||
    window.location.href = `${redirectUri}?captcha_tk=${encodeURIComponent(token)}`;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
(window as any).onHcaptchaSuccess = (token: string) => {
 | 
			
		||||
  if (window.parent !== window) {
 | 
			
		||||
    window.parent.postMessage(`captcha_tk=${token}`, '*');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const redirectUri = route.query.redirect_uri as string;
 | 
			
		||||
  if (redirectUri) {
 | 
			
		||||
    window.location.href = `${redirectUri}?captcha_tk=${encodeURIComponent(token)}`;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// Load CAPTCHA script when component mounts
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
  loadCaptchaScript();
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										174
									
								
								DysonNetwork.Pass/Client/src/views/create-account.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								DysonNetwork.Pass/Client/src/views/create-account.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,174 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="flex items-center justify-center h-full">
 | 
			
		||||
    <n-card class="w-full max-w-md" title="Create a new Solar Network ID">
 | 
			
		||||
      <n-spin :show="isLoading">
 | 
			
		||||
        <n-form
 | 
			
		||||
          ref="formRef"
 | 
			
		||||
          :model="formModel"
 | 
			
		||||
          :rules="rules"
 | 
			
		||||
          @submit.prevent="handleCreateAccount"
 | 
			
		||||
        >
 | 
			
		||||
          <n-form-item path="name" label="Username">
 | 
			
		||||
            <n-input v-model:value="formModel.name" size="large" />
 | 
			
		||||
          </n-form-item>
 | 
			
		||||
          <n-form-item path="nick" label="Nickname">
 | 
			
		||||
            <n-input v-model:value="formModel.nick" size="large" />
 | 
			
		||||
          </n-form-item>
 | 
			
		||||
          <n-form-item path="email" label="Email">
 | 
			
		||||
            <n-input v-model:value="formModel.email" placeholder="your@email.com" size="large" />
 | 
			
		||||
          </n-form-item>
 | 
			
		||||
          <n-form-item path="password" label="Password">
 | 
			
		||||
            <n-input
 | 
			
		||||
              v-model:value="formModel.password"
 | 
			
		||||
              type="password"
 | 
			
		||||
              show-password-on="click"
 | 
			
		||||
              placeholder="Enter your password"
 | 
			
		||||
              size="large"
 | 
			
		||||
            />
 | 
			
		||||
          </n-form-item>
 | 
			
		||||
 | 
			
		||||
          <n-form-item path="captchaToken">
 | 
			
		||||
            <div class="flex justify-center w-full">
 | 
			
		||||
              <captcha
 | 
			
		||||
                :provider="captchaProvider"
 | 
			
		||||
                :api-key="captchaApiKey"
 | 
			
		||||
                @verified="onCaptchaVerified"
 | 
			
		||||
              />
 | 
			
		||||
            </div>
 | 
			
		||||
          </n-form-item>
 | 
			
		||||
 | 
			
		||||
          <n-button type="primary" attr-type="submit" block size="large" :disabled="isLoading">
 | 
			
		||||
            Create Account
 | 
			
		||||
          </n-button>
 | 
			
		||||
 | 
			
		||||
          <div class="mt-3 text-sm text-center opacity-75">
 | 
			
		||||
            <n-button text block @click="router.push('/login')" size="tiny">
 | 
			
		||||
              Already have an account? Login
 | 
			
		||||
            </n-button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </n-form>
 | 
			
		||||
        <n-alert
 | 
			
		||||
          v-if="error"
 | 
			
		||||
          title="Error"
 | 
			
		||||
          type="error"
 | 
			
		||||
          closable
 | 
			
		||||
          @close="error = null"
 | 
			
		||||
          class="mt-4"
 | 
			
		||||
        >
 | 
			
		||||
          {{ error }}
 | 
			
		||||
        </n-alert>
 | 
			
		||||
      </n-spin>
 | 
			
		||||
    </n-card>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { ref, reactive } from 'vue'
 | 
			
		||||
import { useRouter } from 'vue-router'
 | 
			
		||||
import {
 | 
			
		||||
  NCard,
 | 
			
		||||
  NInput,
 | 
			
		||||
  NButton,
 | 
			
		||||
  NSpin,
 | 
			
		||||
  NAlert,
 | 
			
		||||
  NForm,
 | 
			
		||||
  NFormItem,
 | 
			
		||||
  type FormInst,
 | 
			
		||||
  type FormRules,
 | 
			
		||||
  useMessage,
 | 
			
		||||
} from 'naive-ui'
 | 
			
		||||
import Captcha from '@/components/Captcha.vue'
 | 
			
		||||
 | 
			
		||||
const router = useRouter()
 | 
			
		||||
const formRef = ref<FormInst | null>(null)
 | 
			
		||||
 | 
			
		||||
const isLoading = ref(false)
 | 
			
		||||
const error = ref<string | null>(null)
 | 
			
		||||
 | 
			
		||||
const formModel = reactive({
 | 
			
		||||
  name: '',
 | 
			
		||||
  nick: '',
 | 
			
		||||
  email: '',
 | 
			
		||||
  password: '',
 | 
			
		||||
  language: 'en-us',
 | 
			
		||||
  captchaToken: '',
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
const rules: FormRules = {
 | 
			
		||||
  name: [
 | 
			
		||||
    { required: true, message: 'Please enter a username', trigger: 'blur' },
 | 
			
		||||
    {
 | 
			
		||||
      pattern: /^[A-Za-z0-9_-]+$/,
 | 
			
		||||
      message: 'Username can only contain letters, numbers, underscores, and hyphens.',
 | 
			
		||||
      trigger: 'blur',
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
  nick: [{ required: true, message: 'Please enter a nickname', trigger: 'blur' }],
 | 
			
		||||
  email: [
 | 
			
		||||
    { required: true, message: 'Please enter your email', trigger: 'blur' },
 | 
			
		||||
    { type: 'email', message: 'Please enter a valid email address', trigger: ['input', 'blur'] },
 | 
			
		||||
  ],
 | 
			
		||||
  password: [
 | 
			
		||||
    { required: true, message: 'Please enter a password', trigger: 'blur' },
 | 
			
		||||
    { min: 4, message: 'Password must be at least 4 characters long', trigger: 'blur' },
 | 
			
		||||
  ],
 | 
			
		||||
  captchaToken: [{ required: true, message: 'Please complete the captcha verification.' }],
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Get captcha provider and API key from global data
 | 
			
		||||
const captchaProvider = ref((window as any).__APP_DATA__?.Provider || '')
 | 
			
		||||
const captchaApiKey = ref((window as any).__APP_DATA__?.ApiKey || '')
 | 
			
		||||
 | 
			
		||||
const onCaptchaVerified = (token: string) => {
 | 
			
		||||
  formModel.captchaToken = token
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const messageDisplay = useMessage()
 | 
			
		||||
 | 
			
		||||
function handleCreateAccount(e: Event) {
 | 
			
		||||
  e.preventDefault()
 | 
			
		||||
  formRef.value?.validate(async (errors) => {
 | 
			
		||||
    if (errors) {
 | 
			
		||||
      return
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    isLoading.value = true
 | 
			
		||||
    error.value = null
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const response = await fetch('/api/accounts', {
 | 
			
		||||
        method: 'POST',
 | 
			
		||||
        headers: { 'Content-Type': 'application/json' },
 | 
			
		||||
        body: JSON.stringify({
 | 
			
		||||
          name: formModel.name,
 | 
			
		||||
          nick: formModel.nick,
 | 
			
		||||
          email: formModel.email,
 | 
			
		||||
          password: formModel.password,
 | 
			
		||||
          language: formModel.language,
 | 
			
		||||
          captcha_token: formModel.captchaToken,
 | 
			
		||||
        }),
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        const message = await response.text()
 | 
			
		||||
        throw new Error(message || 'Failed to create account.')
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // On success, redirect to login page
 | 
			
		||||
      const messageReactive = messageDisplay.success(
 | 
			
		||||
        'Welcome to Solar Network! Your account has been created successfully.',
 | 
			
		||||
        { duration: 8000 },
 | 
			
		||||
      )
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        messageReactive.type = 'info'
 | 
			
		||||
        messageReactive.content = "Don't forget to check your email for activation instructions."
 | 
			
		||||
      }, 3000)
 | 
			
		||||
      router.push('/login')
 | 
			
		||||
    } catch (e: any) {
 | 
			
		||||
      error.value = e.message
 | 
			
		||||
    } finally {
 | 
			
		||||
      isLoading.value = false
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
@@ -1,7 +1,9 @@
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { ref, onMounted, computed } from 'vue'
 | 
			
		||||
import { NCard, NSpace, NInput, NButton, NSpin, NAlert, NProgress } from 'naive-ui'
 | 
			
		||||
import { useRouter } from 'vue-router'
 | 
			
		||||
import { useRoute, useRouter } from 'vue-router'
 | 
			
		||||
import { useUserStore } from '@/stores/user'
 | 
			
		||||
 | 
			
		||||
import FingerprintJS from '@fingerprintjs/fingerprintjs'
 | 
			
		||||
 | 
			
		||||
// State management
 | 
			
		||||
@@ -190,6 +192,9 @@ async function handleVerifyFactor() {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const userStore = useUserStore()
 | 
			
		||||
const route = useRoute()
 | 
			
		||||
 | 
			
		||||
async function exchangeToken() {
 | 
			
		||||
  isLoading.value = true
 | 
			
		||||
  error.value = null
 | 
			
		||||
@@ -210,7 +215,14 @@ async function exchangeToken() {
 | 
			
		||||
 | 
			
		||||
    const { token } = await response.json()
 | 
			
		||||
    localStorage.setItem('authToken', token)
 | 
			
		||||
    await router.push('/')
 | 
			
		||||
    await userStore.fetchUser()
 | 
			
		||||
 | 
			
		||||
    const redirectUri = route.query.redirect_uri as string
 | 
			
		||||
    if (redirectUri) {
 | 
			
		||||
      window.location.href = redirectUri
 | 
			
		||||
    } else {
 | 
			
		||||
      await router.push('/')
 | 
			
		||||
    }
 | 
			
		||||
  } catch (e: any) {
 | 
			
		||||
    error.value = e.message
 | 
			
		||||
    stage.value = 'select-factor' // Go back if token exchange fails
 | 
			
		||||
@@ -253,6 +265,11 @@ function getFactorName(factorType: number) {
 | 
			
		||||
            <n-button type="primary" block class="mt-4" size="large" @click="handleFindAccount">
 | 
			
		||||
              Continue
 | 
			
		||||
            </n-button>
 | 
			
		||||
            <div class="mt-3 text-sm text-center opacity-75">
 | 
			
		||||
              <n-button text block @click="router.push('/create-account')" size="tiny">
 | 
			
		||||
                Don't have an account? Create one!
 | 
			
		||||
              </n-button>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Stage 2: Select Factor -->
 | 
			
		||||
 
 | 
			
		||||
@@ -25,6 +25,7 @@
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  "AuthToken": {
 | 
			
		||||
    "CookieDomain": "localhost",
 | 
			
		||||
    "PublicKeyPath": "Keys/PublicKey.pem",
 | 
			
		||||
    "PrivateKeyPath": "Keys/PrivateKey.pem"
 | 
			
		||||
  },
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user