first try of x-cross tx per link

This commit is contained in:
clauspeterhuebner 2025-04-11 03:12:05 +02:00
parent f06855e305
commit f1f46b2d80
26 changed files with 733 additions and 75 deletions

View File

@ -7,6 +7,7 @@ export const INALIENABLE_RIGHTS = [
RIGHTS.SEND_RESET_PASSWORD_EMAIL,
RIGHTS.SET_PASSWORD,
RIGHTS.QUERY_TRANSACTION_LINK,
RIGHTS.QUERY_REDEEM_JWT,
RIGHTS.QUERY_OPT_IN,
RIGHTS.CHECK_USERNAME,
RIGHTS.PROJECT_BRANDING_BANNER,

View File

@ -6,6 +6,7 @@ export enum RIGHTS {
SEND_RESET_PASSWORD_EMAIL = 'SEND_RESET_PASSWORD_EMAIL',
SET_PASSWORD = 'SET_PASSWORD',
QUERY_TRANSACTION_LINK = 'QUERY_TRANSACTION_LINK',
QUERY_REDEEM_JWT = 'QUERY_REDEEM_JWT',
QUERY_OPT_IN = 'QUERY_OPT_IN',
CHECK_USERNAME = 'CHECK_USERNAME',
PROJECT_BRANDING_BANNER = 'PROJECT_BRANDING_BANNER',

View File

@ -0,0 +1,37 @@
import { SignJWT, jwtVerify } from 'jose'
import { LogError } from '@/server/LogError'
import { JwtPayloadType } from './payloadtypes/JwtPayloadType'
export const decode = async (token: string, signkey: Buffer): Promise<JwtPayloadType | null> => {
if (!token) throw new LogError('401 Unauthorized')
try {
const secret = new TextEncoder().encode(signkey.toString())
const { payload } = await jwtVerify(token, secret, {
issuer: 'urn:gradido:issuer',
audience: 'urn:gradido:audience',
})
return payload as unknown as JwtPayloadType
} catch (err) {
return null
}
}
export const encode = async (payload: JwtPayloadType, signkey: Buffer): Promise<string> => {
const secret = new TextEncoder().encode(signkey.toString())
const token = await new SignJWT({ payload, 'urn:gradido:claim': true })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer('urn:gradido:issuer')
.setAudience('urn:gradido:audience')
.setExpirationTime(payload.expiration)
.sign(secret)
return token
}
export const decodeJwtType = async (token: string, signkey: Buffer): Promise<string> => {
const payload = await decode(token, signkey)
return payload ? payload.tokentype : 'unknown token type'
}

View File

@ -0,0 +1,33 @@
// import { JWTPayload } from 'jose'
import { JwtPayloadType } from './JwtPayloadType'
export class DisbursementJwtPayloadType extends JwtPayloadType {
static REDEEM_ACTIVATION_TYPE = 'redeem-activation'
sendercommunityuuid: string
sendergradidoid: string
sendername: string // alias or firstname
redeemcode: string
amount: string
memo: string
constructor(
senderCom: string,
senderUser: string,
sendername: string,
code: string,
amount: string,
memo: string,
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
super()
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
this.tokentype = DisbursementJwtPayloadType.REDEEM_ACTIVATION_TYPE
this.sendercommunityuuid = senderCom
this.sendergradidoid = senderUser
this.sendername = sendername
this.redeemcode = code
this.amount = amount
this.memo = memo
}
}

View File

@ -0,0 +1,19 @@
import { JWTPayload } from 'jose'
export class JwtPayloadType implements JWTPayload {
iat?: number | undefined
exp?: number | undefined
nbf?: number | undefined
jti?: string | undefined
aud?: string | string[] | undefined
sub?: string | undefined
iss?: string | undefined;
[propName: string]: unknown
tokentype: string
expiration: string // in minutes (format: 10m for ten minutes)
constructor() {
this.tokentype = 'unknown jwt type'
this.expiration = '10m'
}
}

View File

@ -78,6 +78,7 @@ export class FederationClient {
)
return data.getPublicCommunityInfo
} catch (err) {
logger.warn(' err', err)
const errorString = JSON.stringify(err)
logger.warn('Federation: getPublicCommunityInfo failed for endpoint', {
endpoint: this.endpoint,

View File

@ -12,7 +12,7 @@ export class PublicCommunityInfoLoggingView extends AbstractLoggingView {
return {
name: this.self.name,
description: this.self.description,
creationDate: this.dateToString(this.self.creationDate),
creationDate: this.self.creationDate,
publicKey: this.self.publicKey,
}
}

View File

@ -0,0 +1,37 @@
import { MaxLength, MinLength } from 'class-validator'
import { Decimal } from 'decimal.js-light'
import { Field, ArgsType, InputType } from 'type-graphql'
import { MEMO_MAX_CHARS, MEMO_MIN_CHARS } from '@/graphql/resolver/const/const'
import { IsPositiveDecimal } from '@/graphql/validator/Decimal'
@InputType()
@ArgsType()
export class RedeemJwtArgs {
@Field(() => String, { nullable: false })
gradidoID: string
@Field(() => String, { nullable: true })
alias?: string | null
@Field(() => String, { nullable: true })
firstName?: string | null
@Field(() => String, { nullable: false })
communityUuid: string
@Field(() => String, { nullable: false })
communityName: string
@Field(() => String, { nullable: false })
code: string
@Field(() => Decimal, { nullable: false })
@IsPositiveDecimal()
amount: Decimal
@Field(() => String, { nullable: false })
@MaxLength(MEMO_MAX_CHARS)
@MinLength(MEMO_MIN_CHARS)
memo: string
}

View File

@ -1,26 +1,42 @@
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
import { Community as DbCommunity } from '@entity/Community'
import { TransactionLink as DbTransactionLink } from '@entity/TransactionLink'
import { Decimal } from 'decimal.js-light'
import { ObjectType, Field, Int } from 'type-graphql'
import { CONFIG } from '@/config'
import { Community } from './Community'
import { User } from './User'
@ObjectType()
export class TransactionLink {
constructor(transactionLink: dbTransactionLink, user: User, redeemedBy: User | null = null) {
this.id = transactionLink.id
this.user = user
this.amount = transactionLink.amount
this.holdAvailableAmount = transactionLink.holdAvailableAmount
this.memo = transactionLink.memo
this.code = transactionLink.code
this.createdAt = transactionLink.createdAt
this.validUntil = transactionLink.validUntil
this.deletedAt = transactionLink.deletedAt
this.redeemedAt = transactionLink.redeemedAt
this.redeemedBy = redeemedBy
this.link = CONFIG.COMMUNITY_REDEEM_URL + this.code
constructor(
dbTransactionLink?: DbTransactionLink,
user?: User,
redeemedBy?: User,
dbCommunities?: DbCommunity[],
) {
if (dbTransactionLink !== undefined) {
this.id = dbTransactionLink.id
this.amount = dbTransactionLink.amount
this.holdAvailableAmount = dbTransactionLink.holdAvailableAmount
this.memo = dbTransactionLink.memo
this.code = dbTransactionLink.code
this.link = CONFIG.COMMUNITY_REDEEM_URL + this.code
this.createdAt = dbTransactionLink.createdAt
this.validUntil = dbTransactionLink.validUntil
this.deletedAt = dbTransactionLink.deletedAt
this.redeemedAt = dbTransactionLink.redeemedAt
}
if (user !== undefined) {
this.user = user
}
if (redeemedBy !== undefined) {
this.redeemedBy = redeemedBy
}
if (dbCommunities !== undefined) {
this.communities = dbCommunities.map((dbCom: DbCommunity) => new Community(dbCom))
}
}
@Field(() => Int)
@ -58,6 +74,12 @@ export class TransactionLink {
@Field(() => String)
link: string
@Field(() => String)
communityName: string
@Field(() => [Community])
communities: Community[]
}
@ObjectType()

View File

@ -1,5 +1,5 @@
import { Point } from '@dbTools/typeorm'
import { User as dbUser } from '@entity/User'
import { User as DbUser } from '@entity/User'
import { ObjectType, Field, Int } from 'type-graphql'
import { GmsPublishLocationType } from '@enum/GmsPublishLocationType'
@ -14,41 +14,43 @@ import { UserContact } from './UserContact'
@ObjectType()
export class User {
constructor(user: dbUser | null) {
if (user) {
this.id = user.id
this.foreign = user.foreign
this.communityUuid = user.communityUuid
if (user.community) {
this.communityName = user.community.name
constructor(dbUser: DbUser | null) {
if (dbUser) {
this.id = dbUser.id
this.foreign = dbUser.foreign
this.communityUuid = dbUser.communityUuid
if (dbUser.community) {
this.communityName = dbUser.community.name
}
this.gradidoID = user.gradidoID
this.alias = user.alias
this.gradidoID = dbUser.gradidoID
this.alias = dbUser.alias
const publishNameLogic = new PublishNameLogic(user)
this.humhubUsername = publishNameLogic.getUsername(user.humhubPublishName as PublishNameType)
const publishNameLogic = new PublishNameLogic(dbUser)
this.humhubUsername = publishNameLogic.getUsername(
dbUser.humhubPublishName as PublishNameType,
)
if (user.emailContact) {
this.emailChecked = user.emailContact.emailChecked
this.emailContact = new UserContact(user.emailContact)
if (dbUser.emailContact) {
this.emailChecked = dbUser.emailContact.emailChecked
this.emailContact = new UserContact(dbUser.emailContact)
}
this.firstName = user.firstName
this.lastName = user.lastName
this.deletedAt = user.deletedAt
this.createdAt = user.createdAt
this.language = user.language
this.publisherId = user.publisherId
this.roles = user.userRoles?.map((userRole) => userRole.role) ?? []
this.firstName = dbUser.firstName
this.lastName = dbUser.lastName
this.deletedAt = dbUser.deletedAt
this.createdAt = dbUser.createdAt
this.language = dbUser.language
this.publisherId = dbUser.publisherId
this.roles = dbUser.userRoles?.map((userRole) => userRole.role) ?? []
this.klickTipp = null
this.hasElopage = null
this.hideAmountGDD = user.hideAmountGDD
this.hideAmountGDT = user.hideAmountGDT
this.humhubAllowed = user.humhubAllowed
this.gmsAllowed = user.gmsAllowed
this.gmsPublishName = user.gmsPublishName
this.humhubPublishName = user.humhubPublishName
this.gmsPublishLocation = user.gmsPublishLocation
this.userLocation = user.location ? Point2Location(user.location as Point) : null
this.hideAmountGDD = dbUser.hideAmountGDD
this.hideAmountGDT = dbUser.hideAmountGDT
this.humhubAllowed = dbUser.humhubAllowed
this.gmsAllowed = dbUser.gmsAllowed
this.gmsPublishName = dbUser.gmsPublishName
this.humhubPublishName = dbUser.humhubPublishName
this.gmsPublishLocation = dbUser.gmsPublishLocation
this.userLocation = dbUser.location ? Point2Location(dbUser.location as Point) : null
}
}

View File

@ -1,6 +1,7 @@
import { randomBytes } from 'crypto'
import { getConnection } from '@dbTools/typeorm'
import { Community as DbCommunity } from '@entity/Community'
import { Contribution as DbContribution } from '@entity/Contribution'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { Transaction as DbTransaction } from '@entity/Transaction'
@ -10,6 +11,7 @@ import { Decimal } from 'decimal.js-light'
import { Resolver, Args, Arg, Authorized, Ctx, Mutation, Query, Int } from 'type-graphql'
import { Paginated } from '@arg/Paginated'
import { RedeemJwtArgs } from '@arg/RedeemJwtArgs'
import { TransactionLinkArgs } from '@arg/TransactionLinkArgs'
import { TransactionLinkFilters } from '@arg/TransactionLinkFilters'
import { ContributionCycleType } from '@enum/ContributionCycleType'
@ -22,6 +24,8 @@ import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink'
import { User } from '@model/User'
import { QueryLinkResult } from '@union/QueryLinkResult'
import { decode, encode } from '@/auth/jwt/JWT'
import { DisbursementJwtPayloadType } from '@/auth/jwt/payloadtypes/DisbursementJwtPayloadType'
import { RIGHTS } from '@/auth/RIGHTS'
import {
EVENT_CONTRIBUTION_LINK_REDEEM,
@ -39,6 +43,7 @@ import { fullName } from '@/util/utilities'
import { calculateBalance } from '@/util/validate'
import { executeTransaction } from './TransactionResolver'
import { getAuthenticatedCommunities, getHomeCommunity } from './util/communities'
import { getUserCreation, validateContribution } from './util/creations'
import { getLastTransaction } from './util/getLastTransaction'
import { sendTransactionsToDltConnector } from './util/sendTransactionsToDltConnector'
@ -136,6 +141,8 @@ export class TransactionLinkResolver {
@Authorized([RIGHTS.QUERY_TRANSACTION_LINK])
@Query(() => QueryLinkResult)
async queryTransactionLink(@Arg('code') code: string): Promise<typeof QueryLinkResult> {
logger.debug('TransactionLinkResolver.queryTransactionLink... code=', code)
const transactionLink = new TransactionLink()
if (code.match(/^CL-/)) {
const contributionLink = await DbContributionLink.findOneOrFail({
where: { code: code.replace('CL-', '') },
@ -143,19 +150,48 @@ export class TransactionLinkResolver {
})
return new ContributionLink(contributionLink)
} else {
const transactionLink = await DbTransactionLink.findOneOrFail({
where: { code },
withDeleted: true,
})
const user = await DbUser.findOneOrFail({ where: { id: transactionLink.userId } })
let redeemedBy: User | null = null
if (transactionLink?.redeemedBy) {
redeemedBy = new User(
await DbUser.findOneOrFail({ where: { id: transactionLink.redeemedBy } }),
)
let txLinkFound = false
let dbTransactionLink!: DbTransactionLink
try {
dbTransactionLink = await DbTransactionLink.findOneOrFail({
where: { code },
withDeleted: true,
})
txLinkFound = true
} catch (err) {
txLinkFound = false
}
// normal redeem code
if (txLinkFound) {
const user = await DbUser.findOneOrFail({ where: { id: dbTransactionLink.userId } })
let redeemedBy
if (dbTransactionLink.redeemedBy) {
redeemedBy = new User(
await DbUser.findOneOrFail({ where: { id: dbTransactionLink.redeemedBy } }),
)
}
const communities = await getAuthenticatedCommunities()
return new TransactionLink(dbTransactionLink, new User(user), redeemedBy, communities)
} else {
// disbursement jwt-token
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment
const homeCom = await getHomeCommunity()
const jwtPayload = decode(code, homeCom.publicKey)
if (jwtPayload !== null && jwtPayload instanceof DisbursementJwtPayloadType) {
const disburseJwtPayload: DisbursementJwtPayloadType = jwtPayload
transactionLink.communityName = homeCom.name !== null ? homeCom.name : 'unknown'
// transactionLink.user = new User()
transactionLink.user.alias = disburseJwtPayload.sendername
transactionLink.amount = new Decimal(disburseJwtPayload.amount)
transactionLink.memo = disburseJwtPayload.memo
transactionLink.code = disburseJwtPayload.redeemcode
return transactionLink
} else {
throw new LogError('Redeem with wrong type of JWT-Token! jwtType=', jwtPayload)
}
}
return new TransactionLink(transactionLink, new User(user), redeemedBy)
}
return transactionLink
}
@Authorized([RIGHTS.REDEEM_TRANSACTION_LINK])
@ -364,6 +400,36 @@ export class TransactionLinkResolver {
}
}
@Authorized([RIGHTS.QUERY_REDEEM_JWT])
@Mutation(() => String)
async createRedeemJwt(@Args() redeemJwtArgs: RedeemJwtArgs): Promise<string> {
logger.debug('TransactionLinkResolver.queryRedeemJwt... args=', {
gradidoID: redeemJwtArgs.gradidoID,
alias: redeemJwtArgs.alias,
firstName: redeemJwtArgs.firstName,
communityUuid: redeemJwtArgs.communityUuid,
communityName: redeemJwtArgs.communityName,
code: redeemJwtArgs.code,
amount: redeemJwtArgs.amount,
memo: redeemJwtArgs.memo,
})
const disbursementJwtPayloadType = new DisbursementJwtPayloadType(
redeemJwtArgs.communityUuid,
redeemJwtArgs.gradidoID,
redeemJwtArgs.alias ?? redeemJwtArgs.firstName ?? '',
redeemJwtArgs.code,
redeemJwtArgs.amount.toString(),
redeemJwtArgs.memo,
)
const homeCom = await getHomeCommunity()
if (!homeCom.privateKey) {
throw new LogError('Home community private key is not set')
}
const redeemJwt = await encode(disbursementJwtPayloadType, homeCom.privateKey)
return redeemJwt
}
@Authorized([RIGHTS.LIST_TRANSACTION_LINKS])
@Query(() => TransactionLinkResult)
async listTransactionLinks(

View File

@ -1,4 +1,4 @@
import { FindOneOptions } from '@dbTools/typeorm'
import { FindOneOptions, IsNull, Not } from '@dbTools/typeorm'
import { Community as DbCommunity } from '@entity/Community'
import { FederatedCommunity as DbFederatedCommunity } from '@entity/FederatedCommunity'
@ -87,6 +87,17 @@ export async function getCommunityByUuid(communityUuid: string): Promise<DbCommu
})
}
export async function getAuthenticatedCommunities(): Promise<DbCommunity[]> {
const dbCommunities: DbCommunity[] = await DbCommunity.find({
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
where: { communityUuid: Not(IsNull()) }, //, authenticatedAt: Not(IsNull()) },
order: {
name: 'ASC',
},
})
return dbCommunities
}
export async function getCommunityByIdentifier(
communityIdentifier: string,
): Promise<DbCommunity | null> {

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -46,6 +46,7 @@ const validCommunityIdentifier = ref(false)
const { onResult } = useQuery(selectCommunities)
onResult(({ data }) => {
console.log('CommunitySwitch.onResult...data=', data)
if (data) {
communities.value = data.communities
setDefaultCommunity()
@ -55,22 +56,29 @@ onResult(({ data }) => {
const communityIdentifier = computed(() => route.params.communityIdentifier)
function updateCommunity(community) {
console.log('CommunitySwitch.updateCommunity...community=', community)
emit('update:model-value', community)
}
function setDefaultCommunity() {
console.log('CommunitySwitch.setDefaultCommunity... communityIdentifier= communities=', communityIdentifier, communities)
if (communityIdentifier.value && communities.value.length >= 1) {
console.log('CommunitySwitch.setDefaultCommunity... communities.value.length=', communities.value.length)
const foundCommunity = communities.value.find((community) => {
console.log('CommunitySwitch.setDefaultCommunity... community=', community)
if (
community.uuid === communityIdentifier.value ||
community.name === communityIdentifier.value
) {
validCommunityIdentifier.value = true
console.log('CommunitySwitch.setDefaultCommunity...true validCommunityIdentifier=', validCommunityIdentifier)
return true
}
console.log('CommunitySwitch.setDefaultCommunity...false validCommunityIdentifier=', validCommunityIdentifier)
return false
})
if (foundCommunity) {
console.log('CommunitySwitch.setDefaultCommunity...foundCommunity=', foundCommunity)
updateCommunity(foundCommunity)
return
}
@ -79,10 +87,13 @@ function setDefaultCommunity() {
if (validCommunityIdentifier.value && !communityIdentifier.value) {
validCommunityIdentifier.value = false
console.log('CommunitySwitch.setDefaultCommunity...validCommunityIdentifier=', validCommunityIdentifier)
}
if (props.modelValue?.uuid === '' && communities.value.length) {
console.log('CommunitySwitch.setDefaultCommunity...props.modelValue= communities=', props.modelValue, communities.value.length)
const foundCommunity = communities.value.find((community) => !community.foreign)
console.log('CommunitySwitch.setDefaultCommunity...foundCommunity=', foundCommunity)
if (foundCommunity) {
updateCommunity(foundCommunity)
}

View File

@ -0,0 +1,103 @@
<template>
<div
:link-data="linkData"
:redeem-code="redeemCode"
:is-contribution-link="isContributionLink"
class="redeem-community-selection"
>
<BCard bg-variant="muted" text-variant="dark" border-variant="info">
<h1 v-if="linkData.amount === ''">{{ $t('gdd_per_link.redeemlink-error') }}</h1>
<h1 v-if="!isContributionLink && linkData.amount !== ''">
<BCol class="mb-4" cols="12">
<BRow>
<BCol>{{ $t('gdd_per_link.recipientCommunity') }}</BCol>
</BRow>
<BRow>
<BCol class="fw-bold">
<community-switch
:disabled="isBalanceDisabled"
:model-value="targetCommunity"
@update:model-value="setTargetCommunity"
/>
</BCol>
<BCol v-if="isForeignCommunitySelected" sm="12" md="6" class="mt-4 mt-lg-0">
<p>{{ $t('gdd_per_link.switchCommunity') }}</p>
<BButton variant="gradido" @click="onSwitch">
{{ $t('gdd_per_link.to-switch') }}
</BButton>
</BCol>
</BRow>
</BCol>
<template v-if="linkData.user">
{{ linkData.user.firstName }}
{{ $t('transaction-link.send_you') }} {{ $filters.GDD(linkData.amount) }}
</template>
</h1>
<b>{{ linkData.memo }}</b>
</BCard>
</div>
</template>
<script setup>
import CONFIG from '@/config'
import { computed } from 'vue'
import { createRedeemJwtMutation } from '@/graphql/mutations'
import { useMutation } from '@vue/apollo-composable'
const props = defineProps({
linkData: { type: Object, required: true },
redeemCode: { type: String, required: true },
isContributionLink: { type: Boolean, default: false },
targetCommunity: {
type: Object,
required: true,
default: () => ({ uuid: '', name: CONFIG.COMMUNITY_NAME }),
},
})
const emit = defineEmits(['update:targetCommunity'])
const isForeignCommunitySelected = computed(() => {
if (props.targetCommunity.name !== CONFIG.COMMUNITY_NAME) {
return true
}
return false
})
function setTargetCommunity(community) {
console.log('RedeemCommunitySelection.setTargetCommunity...community=', community)
emit('update:targetCommunity', community)
}
const { mutate: createRedeemJwt } = useMutation(createRedeemJwtMutation)
async function onSwitch(event) {
event.preventDefault() // Prevent the default navigation
console.log('RedeemCommunitySelection.onSwitch... props=', props)
if (isForeignCommunitySelected.value) {
const redeemJwtArgs = {
gradidoID: props.linkData.user.gradidoID,
firstName: props.linkData.user.firstName,
alias: props.linkData.user.alias,
communityUuid: props.targetCommunity.uuid,
communityName: props.targetCommunity.name,
code: props.redeemCode,
amount: props.linkData.amount,
memo: props.linkData.memo,
}
console.log('RedeemCommunitySelection.onSwitch vor createRedeemJwt params:', redeemJwtArgs)
try {
const { data } = await createRedeemJwt({ variables: redeemJwtArgs })
console.log('RedeemCommunitySelection.onSwitch... response=', data)
if (!data?.createRedeemJwt) {
throw new Error('Failed to get redeem token')
}
const targetUrl = props.targetCommunity.url.replace(/\/api\/?$/, '')
window.location.href = targetUrl + '/redeem/' + data.createRedeemJwt
} catch (error) {
console.error('RedeemCommunitySelection.onSwitch error:', error)
throw error
}
}
}
</script>

View File

@ -0,0 +1,49 @@
<template>
<div class="redeem-select-community">
<redeem-community-selection
v-model:target-community="yourTargetCommunity"
:link-data="props.linkData"
:redeem-code="props.redeemCode"
:is-contribution-link="props.isContributionLink"
/>
<BCard>
<div class="mb-2">
<h2>{{ $t('gdd_per_link.redeem') }}</h2>
</div>
<BRow>
<BCol sm="12" md="6">
<p>{{ $t('gdd_per_link.no-account') }}</p>
<BButton variant="primary" :to="register()">
{{ $t('gdd_per_link.to-register') }}
</BButton>
</BCol>
<BCol sm="12" md="6" class="mt-4 mt-lg-0">
<p>{{ $t('gdd_per_link.has-account') }}</p>
<BButton variant="gradido" :to="login()">
{{ $t('gdd_per_link.to-login') }}
</BButton>
</BCol>
</BRow>
</BCard>
</div>
</template>
<script setup>
import { ref } from 'vue'
import CONFIG from '@/config'
import { useAuthLinks } from '@/composables/useAuthLinks'
const { login, register } = useAuthLinks()
const props = defineProps({
linkData: { type: Object, required: true },
redeemCode: { type: String, required: true },
isContributionLink: { type: Boolean, default: false },
})
const yourTargetCommunity = ref({
uuid: '',
name: CONFIG.COMMUNITY_NAME,
})
</script>

View File

@ -198,3 +198,27 @@ export const logout = gql`
logout
}
`
export const createRedeemJwtMutation = gql`
mutation (
$gradidoID: String!
$firstName: String!
$alias: String!
$communityUuid: String!
$communityName: String!
$code: String!
$amount: Decimal!
$memo: String!
) {
createRedeemJwt(
gradidoID: $gradidoID
firstName: $firstName
alias: $alias
communityUuid: $communityUuid
communityName: $communityName
code: $code
amount: $amount
memo: $memo
)
}
`

View File

@ -110,6 +110,7 @@ export const selectCommunities = gql`
name
description
foreign
url
}
}
`
@ -142,6 +143,13 @@ export const queryTransactionLink = gql`
firstName
publisherId
}
communities {
foreign
name
description
url
uuid
}
}
... on ContributionLink {
id

View File

@ -251,12 +251,15 @@
"no-account": "Du besitzt noch kein Gradido Konto?",
"no-redeem": "Du darfst deinen eigenen Link nicht einlösen!",
"not-copied": "Dein Gerät lässt das Kopieren leider nicht zu! Bitte kopiere den Link von Hand!",
"recipientCommunity": "Wähle deine Gemeinschaft zum Einlösen des Link-Guthabens...",
"redeem": "Einlösen",
"redeemed": "Erfolgreich eingelöst! Deinem Konto wurden {n} GDD gutgeschrieben.",
"redeemed-at": "Der Link wurde bereits am {date} eingelöst.",
"redeemlink-error": "Dieser Einlöse-Link ist nicht vollständig.",
"switchCommunity": "Du hast eine andere Gemeinschaft ausgewählt...",
"to-login": "Log dich ein",
"to-register": "Registriere ein neues Konto.",
"to-switch": "Wechsle zur Gemeinschaft",
"validUntil": "Gültig bis",
"validUntilDate": "Der Link ist bis zum {date} gültig."
},

View File

@ -251,12 +251,15 @@
"no-account": "You don't have a Gradido account yet?",
"no-redeem": "You not allowed to redeem your own link!",
"not-copied": "Unfortunately, your device does not allow copying! Please copy the link by hand!",
"recipientCommunity": "Select your Community to redeem the link-deposit...",
"redeem": "Redeem",
"redeemed": "Successfully redeemed! Your account has been credited with {n} GDD.",
"redeemed-at": "The link was already redeemed on {date}.",
"redeemlink-error": "This redemption link is not complete.",
"switchCommunity": "You have selected a foreign Community...",
"to-login": "Log in",
"to-register": "Register a new account.",
"to-switch": "Switch to Community",
"validUntil": "Valid until",
"validUntilDate": "The link is valid until {date}."
},

View File

@ -191,12 +191,15 @@
"no-account": "Aún no tienes una cuenta de Gradido?",
"no-redeem": "No puedes canjear tu propio enlace!",
"not-copied": "¡Desafortunadamente, su dispositivo no permite copiar! Copie el enlace manualmente!",
"recipientCommunity": "Select your Community to redeem the link-deposit...",
"redeem": "Canjear",
"redeemed": "¡Canjeado con éxito! Tu cuenta ha sido acreditada con {n} GDD.",
"redeemed-at": "El enlace ya se canjeó el {date}.",
"redeemed-title": "canjeado",
"switchCommunity": "You have selected a foreign Community...",
"to-login": "iniciar sesión",
"to-register": "Registre una nueva cuenta.",
"to-switch": "Switch to Community",
"validUntil": "Válido hasta",
"validUntilDate": "El enlace es válido hasta el {date} ."
},

View File

@ -197,12 +197,15 @@
"no-account": "Vous n'avez pas encore de compte Gradido?",
"no-redeem": "Vous n'êtes pas autorisé à percevoir votre propre lien!",
"not-copied": "Malheureusement votre appareil ne permet pas de copier! Veuillez copier le lien manuellement svp!",
"recipientCommunity": "Select your Community to redeem the link-deposit...",
"redeem": "Encaisser",
"redeemed": "Encaissé avec succès! Votre compte est crédité de {n} GDD.",
"redeemed-at": "Le lien a déjà été perçu le {date}.",
"redeemed-title": "encaisser",
"switchCommunity": "You have selected a foreign Community...",
"to-login": "Connexion",
"to-register": "Enregistrer un nouveau compte.",
"to-switch": "Switch to Community",
"validUntil": "Valide jusqu'au",
"validUntilDate": "Le lien est valide jusqu'au {date}."
},

View File

@ -191,12 +191,15 @@
"no-account": "Je hebt nog geen Gradido rekening?",
"no-redeem": "Je mag je eigen link niet inwisselen!",
"not-copied": "Jouw apparaat laat het kopiëren helaas niet toe! Kopieer de link alsjeblieft met de hand!",
"recipientCommunity": "Select your Community to redeem the link-deposit...",
"redeem": "Inwisselen",
"redeemed": "Succesvol ingewisseld! Op jouw rekening werden {n} GDD bijgeschreven.",
"redeemed-at": "De link werd al op {date} ingewisseld.",
"redeemed-title": "ingewisseld",
"switchCommunity": "You have selected a foreign Community...",
"to-login": "Inloggen",
"to-register": "Registreer een nieuwe rekening.",
"to-switch": "Switch to Community",
"validUntil": "Geldig tot",
"validUntilDate": "De link is geldig tot {date}."
},

View File

@ -176,13 +176,16 @@
"no-account": "Henüz bir Gradido hesabınız yok mu?",
"no-redeem": "Kendi linkini kullanarak GDD'lerini paraya dönüştüremezsin!",
"not-copied": "Maalesef cihazın kopyalamaya izin vermiyor! Lütfen bağlantıyı elle kopyala!",
"recipientCommunity": "Select your Community to redeem the link-deposit...",
"redeem": "Paraya dönüştürme",
"redeem-text": "Şu anda paraya dönüştürmek ister misin?",
"redeemed": "Başarıyla dönüştürüldü! Hesabına {n} GDD yatırıldı.",
"redeemed-at": "Link {date} tarihinde paraya dönüştürüldü.",
"redeemed-title": "paraya dönüştürüldü",
"switchCommunity": "You have selected a foreign Community...",
"to-login": "Giriş yap",
"to-register": "Yeni hesap aç.",
"to-switch": "Switch to Community",
"validUntil": "Geçerlilik süresi",
"validUntilDate": "Link {date} tarihine kadar geçerli."
},

View File

@ -2,8 +2,12 @@
<div class="show-transaction-link-informations">
<div class="mt-4">
<transaction-link-item :type="itemTypeExt">
<template #LOGGED_OUT>
<redeem-logged-out :link-data="linkData" :is-contribution-link="isContributionLink" />
<template #REDEEM_SELECT_COMMUNITY>
<redeem-select-community
:link-data="linkData"
:redeem-code="redeemCode"
:is-contribution-link="isContributionLink"
/>
</template>
<template #SELF_CREATOR>
@ -33,7 +37,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useStore } from 'vuex'
import { useQuery, useMutation } from '@vue/apollo-composable'
import TransactionLinkItem from '@/components/TransactionLinkItem'
import RedeemLoggedOut from '@/components/LinkInformations/RedeemLoggedOut'
import RedeemSelectCommunity from '@/components/LinkInformations/RedeemSelectCommunity'
import RedeemSelfCreator from '@/components/LinkInformations/RedeemSelfCreator'
import RedeemValid from '@/components/LinkInformations/RedeemValid'
import RedeemedTextBox from '@/components/LinkInformations/RedeemedTextBox'
@ -52,11 +56,17 @@ const linkData = ref({
__typename: 'TransactionLink',
amount: '',
memo: '',
user: {
firstName: '',
},
user: null,
deletedAt: null,
validLink: false,
communities: [],
// ContributionLink fields
validTo: null,
validFrom: null,
name: '',
cycle: null,
link: '',
maxAmountPerMonth: null,
})
const redeemedBoxText = ref('')
@ -75,6 +85,8 @@ const isContributionLink = computed(() => {
return params.code?.search(/^CL-/) === 0
})
const redeemCode = computed(() => params.code)
const tokenExpiresInSeconds = computed(() => {
const remainingSecs = Math.floor(
(new Date(store.state.tokenTime * 1000).getTime() - new Date().getTime()) / 1000,
@ -87,18 +99,34 @@ const validLink = computed(() => {
})
const itemType = computed(() => {
if (linkData.value.deletedAt) return 'TEXT_DELETED'
if (new Date(linkData.value.validUntil) < new Date()) return 'TEXT_EXPIRED'
if (linkData.value.redeemedAt) return 'TEXT_REDEEMED'
if (store.state.token && store.state.tokenTime) {
if (tokenExpiresInSeconds.value < 5) return 'LOGGED_OUT'
if (linkData.value.user && store.state.gradidoID === linkData.value.user.gradidoID)
return 'SELF_CREATOR'
if (!linkData.value.redeemedAt && !linkData.value.deletedAt) return 'VALID'
if (linkData.value.deletedAt) {
console.log('TransactionLink.itemType... TEXT_DELETED')
return 'TEXT_DELETED'
}
return 'LOGGED_OUT'
if (new Date(linkData.value.validUntil) < new Date()) {
console.log('TransactionLink.itemType... TEXT_EXPIRED')
return 'TEXT_EXPIRED'
}
if (linkData.value.redeemedAt) {
console.log('TransactionLink.itemType... TEXT_REDEEMED')
return 'TEXT_REDEEMED'
}
if (store.state.token && store.state.tokenTime) {
if (tokenExpiresInSeconds.value < 5) {
console.log('TransactionLink.itemType... REDEEM_SELECT_COMMUNITY')
return 'REDEEM_SELECT_COMMUNITY'
}
if (linkData.value.user && store.state.gradidoID === linkData.value.user.gradidoID) {
console.log('TransactionLink.itemType... SELF_CREATOR')
return 'SELF_CREATOR'
}
if (!linkData.value.redeemedAt && !linkData.value.deletedAt) {
console.log('TransactionLink.itemType... VALID')
return 'VALID'
}
}
console.log('TransactionLink.itemType...last return= REDEEM_SELECT_COMMUNITY')
return 'REDEEM_SELECT_COMMUNITY'
})
const itemTypeExt = computed(() => {
@ -109,10 +137,12 @@ const itemTypeExt = computed(() => {
})
watch(itemType, (newItemType) => {
console.log('TransactionLink.watch... itemType=', itemType)
updateRedeemedBoxText(newItemType)
})
function updateRedeemedBoxText(type) {
console.log('TransactionLink.updateRedeemedBoxText... type=', type)
switch (type) {
case 'TEXT_DELETED':
redeemedBoxText.value = t('gdd_per_link.link-deleted', {
@ -132,15 +162,18 @@ function updateRedeemedBoxText(type) {
default:
redeemedBoxText.value = ''
}
console.log('TransactionLink.updateRedeemedBoxText... redeemedBoxText=', redeemedBoxText)
}
const emit = defineEmits(['set-mobile-start'])
onMounted(() => {
console.log('TransactionLink.onMounted... params=', params)
emit('set-mobile-start', false)
})
onResult(() => {
console.log('TransactionLink.onResult... result=', result)
if (!result || !result.value) return
setTransactionLinkInformation()
})
@ -150,19 +183,27 @@ onError(() => {
})
function setTransactionLinkInformation() {
console.log('TransactionLink.setTransactionLinkInformation... result=', result)
const { queryTransactionLink } = result.value
console.log(
'TransactionLink.setTransactionLinkInformation... queryTransactionLink=',
queryTransactionLink,
)
if (queryTransactionLink) {
linkData.value = queryTransactionLink
console.log('TransactionLink.setTransactionLinkInformation... linkData.value=', linkData.value)
if (linkData.value.__typename === 'ContributionLink' && store.state.token) {
console.log('TransactionLink.setTransactionLinkInformation... typename === ContributionLink')
mutationLink(linkData.value.amount)
}
}
}
async function mutationLink(amount) {
console.log('TransactionLink.mutationLink... params=', params)
try {
await redeemMutate({
code: params.code,
code: redeemCode.value,
})
toastSuccess(t('gdd_per_link.redeemed', { n: amount }))
await router.push('/overview')

View File

@ -0,0 +1,174 @@
<template>
<div class="show-transaction-link-informations">
<div class="mt-4">
<transaction-link-item :type="itemTypeExt">
<template #LOGGED_OUT>
<redeem-logged-out :link-data="linkData" :is-contribution-link="isContributionLink" />
</template>
<template #SELF_CREATOR>
<redeem-self-creator :link-data="linkData" />
</template>
<template #VALID>
<redeem-valid
:link-data="linkData"
:is-contribution-link="isContributionLink"
:valid-link="validLink"
@mutation-link="mutationLink"
/>
</template>
<template #TEXT>
<redeemed-text-box :text="redeemedBoxText" />
</template>
</transaction-link-item>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useStore } from 'vuex'
import { useQuery, useMutation } from '@vue/apollo-composable'
import TransactionLinkItem from '@/components/TransactionLinkItem'
import RedeemLoggedOut from '@/components/LinkInformations/RedeemLoggedOut'
import RedeemSelfCreator from '@/components/LinkInformations/RedeemSelfCreator'
import RedeemValid from '@/components/LinkInformations/RedeemValid'
import RedeemedTextBox from '@/components/LinkInformations/RedeemedTextBox'
import { useAppToast } from '@/composables/useToast'
import { queryTransactionLink } from '@/graphql/queries'
import { redeemTransactionLink } from '@/graphql/mutations'
import { useI18n } from 'vue-i18n'
const { toastError, toastSuccess } = useAppToast()
const router = useRouter()
const { params } = useRoute()
const store = useStore()
const { d, t } = useI18n()
const linkData = ref({
__typename: 'TransactionLink',
amount: '',
memo: '',
user: {
firstName: '',
},
deletedAt: null,
validLink: false,
})
const redeemedBoxText = ref('')
const { result, onResult, loading, error, onError } = useQuery(queryTransactionLink, {
code: params.code,
})
const {
mutate: redeemMutate,
loading: redeemLoading,
error: redeemError,
} = useMutation(redeemTransactionLink)
const isContributionLink = computed(() => {
return params.code?.search(/^CL-/) === 0
})
const tokenExpiresInSeconds = computed(() => {
const remainingSecs = Math.floor(
(new Date(store.state.tokenTime * 1000).getTime() - new Date().getTime()) / 1000,
)
return remainingSecs <= 0 ? 0 : remainingSecs
})
const validLink = computed(() => {
return new Date(linkData.value.validUntil) > new Date()
})
const itemType = computed(() => {
if (linkData.value.deletedAt) return 'TEXT_DELETED'
if (new Date(linkData.value.validUntil) < new Date()) return 'TEXT_EXPIRED'
if (linkData.value.redeemedAt) return 'TEXT_REDEEMED'
if (store.state.token && store.state.tokenTime) {
if (tokenExpiresInSeconds.value < 5) return 'LOGGED_OUT'
if (linkData.value.user && store.state.gradidoID === linkData.value.user.gradidoID)
return 'SELF_CREATOR'
if (!linkData.value.redeemedAt && !linkData.value.deletedAt) return 'VALID'
}
return 'LOGGED_OUT'
})
const itemTypeExt = computed(() => {
if (itemType.value.startsWith('TEXT')) {
return 'TEXT'
}
return itemType.value
})
watch(itemType, (newItemType) => {
updateRedeemedBoxText(newItemType)
})
function updateRedeemedBoxText(type) {
switch (type) {
case 'TEXT_DELETED':
redeemedBoxText.value = t('gdd_per_link.link-deleted', {
date: d(new Date(linkData.value.deletedAt), 'long'),
})
break
case 'TEXT_EXPIRED':
redeemedBoxText.value = t('gdd_per_link.link-expired', {
date: d(new Date(linkData.value.validUntil), 'long'),
})
break
case 'TEXT_REDEEMED':
redeemedBoxText.value = t('gdd_per_link.redeemed-at', {
date: d(new Date(linkData.value.redeemedAt), 'long'),
})
break
default:
redeemedBoxText.value = ''
}
}
const emit = defineEmits(['set-mobile-start'])
onMounted(() => {
emit('set-mobile-start', false)
})
onResult(() => {
if (!result || !result.value) return
setTransactionLinkInformation()
})
onError(() => {
toastError(t('gdd_per_link.redeemlink-error'))
})
function setTransactionLinkInformation() {
const { queryTransactionLink } = result.value
if (queryTransactionLink) {
linkData.value = queryTransactionLink
if (linkData.value.__typename === 'ContributionLink' && store.state.token) {
mutationLink(linkData.value.amount)
}
}
}
async function mutationLink(amount) {
try {
await redeemMutate({
code: params.code,
})
toastSuccess(t('gdd_per_link.redeemed', { n: amount }))
await router.push('/overview')
} catch (err) {
toastError(err.message)
await router.push('/overview')
}
}
</script>